commit c61193f2af247c59cac35c76c0c445c808b5a24e Author: guzhujushi Date: Sat Aug 22 22:28:41 2026 +0800 Initial commit: MyBlog full stack blog diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..641c771 --- /dev/null +++ b/.env.example @@ -0,0 +1,193 @@ +# ============================================================ +# MyBlog 环境变量模板 +# 用法:复制本文件为 .env,填入真实值(.env 已被 git 忽略,勿提交) +# 说明:所有变量均可选填;不填时后端使用各变量下方的“默认值” +# ============================================================ + +# ---------------- 基础配置 ---------------- + +# JWT 签名密钥(强烈建议必填) +# 作用:给登录令牌签名,密钥泄露 = 可以伪造任意用户身份 +# 生成方式:python -c "import secrets; print(secrets.token_urlsafe(48))" +# 注意:后端启动时会校验,缺失则直接报错拒绝启动 +JWT_SECRET= + +# JWT 令牌有效期(单位:分钟),默认 10080(7 天) +# 调小(如 1440 = 1 天):登录态更快过期,更安全但需更频繁登录 +# 调大(如 43200 = 30 天):体验更好但令牌泄露风险时间更长 +JWT_EXPIRE_MINUTES=10080 + +# 数据库地址(可选),默认:backend 目录下的 blog.db(SQLite 文件) +# 保持默认即可;如需换路径: +# Windows 示例:sqlite:///D:/MyBlog/backend/blog.db +# Linux 示例: sqlite:////var/www/blog/backend/blog.db +DATABASE_URL= + +# 上传文件根目录(可选),默认:项目根目录的 uploads/ +# 部署时务必与 Nginx 的 /uploads/ 静态映射指向同一目录 +# 相对路径按项目根目录解析 +UPLOADS_DIR= + +# ---------------- 头像设置(可选)---------------- + +# 头像文件大小上限(MB),默认 4 +# 作用:头像上传接口拒绝超过该大小的文件(仅支持 jpg/png/webp) +# 调大(如 8):允许更高清头像;调小(如 1):更省存储 +AVATAR_MAX_SIZE_MB=4 + +# 头像更换冷却时间(小时),默认 24 +# 作用:同一账号两次更换头像之间至少间隔该时长,防止反复更换头像 +# 调大(如 168 = 1 周):更严格;调小(如 1):更宽松 +AVATAR_CHANGE_INTERVAL_HOURS=24 + +# ---------------- 博主账号(仅初始化用)---------------- + +# 作用:执行 python -m backend.seed 时创建唯一博主账号(bcrypt 存储) +# 账号已存在时重复执行会跳过,不会覆盖已有账号 +BLOGGER_USERNAME=孤竹居士 +BLOGGER_EMAIL=guzhujushi2008@163.com +BLOGGER_PASSWORD=你的登录密码 + +# ---------------- SMTP 邮箱验证码 ---------------- + +# 作用:注册/忘记密码时向用户邮箱发送验证码(163 邮箱示例) +# SMTP_PORT:465=SSL 加密(推荐);587/25=普通 STARTTLS +# SMTP_AUTH_CODE:邮箱的“授权码”,不是登录密码(163 在设置里开启 SMTP 后获取) +SMTP_HOST=smtp.163.com +SMTP_PORT=465 +SMTP_USER=发件邮箱地址 +SMTP_AUTH_CODE=授权码 +EMAIL_FROM=发件邮箱地址(默认与 SMTP_USER 相同) + +# ---------------- 文章图片上传(可选)---------------- + +# 文章图片(封面 / 正文插图)大小上限(MB),默认 6 +# 作用:发文时上传封面与正文图片的接口拒绝超过该大小的文件(仅支持 jpg/png/webp) +# 调大(如 10):允许更高清大图;调小(如 3):更省存储 +ARTICLE_IMAGE_MAX_SIZE_MB=6 + +# ---------------- 文章文档上传(可选)---------------- + +# 文章 Word 文档(doc/docx)大小上限(MB),默认 20 +# 作用:发文编辑器“插入文档”上传接口拒绝超过该大小的文件 +# 注意:文档只能用于“我的生活 / 我的学习”分区的文章(后端强制校验分区) +# 调大(如 50):允许更大的文档;调小(如 10):更省存储 +DOC_MAX_SIZE_MB=20 + +# ---------------- 安全限流(可选,调整防护强度)---------------- + +# 登录失败次数上限,默认 5 +# 作用:同一邮箱在窗口内失败达到该次数后,即使密码正确也会被临时拒绝 +# 调小(如 3):更安全,但误输密码几次就被锁 +# 调大(如 10):更宽松,防爆破能力下降 +LOGIN_MAX_FAILURES=5 + +# 登录锁定窗口(秒),默认 900 = 15 分钟 +LOGIN_WINDOW_SECONDS=900 + +# 同一 IP 在登录窗口内最多尝试次数,默认 30 +# 作用:配合邮箱维度限流,防止攻击者换着邮箱对博主账号做分布式爆破 +# 调小(如 10):更严格,但同一 NAT 出口下的多人可能互相影响 +LOGIN_MAX_PER_IP=30 + +# 同一 IP 每小时最多注册次数,默认 3 +# 作用:注册已强制要求邮箱验证码,此处再加 IP 限流,防批量注册垃圾账号 +REGISTER_MAX_PER_IP=3 +REGISTER_WINDOW_SECONDS=3600 + +# 评论 / 点赞每分钟最多次数(按 IP),默认 30 +# 作用:防止好友账号刷评论 / 刷点赞 +ACTION_MAX_PER_MINUTE=30 + +# 同一邮箱每小时最多发送验证码次数,默认 5 +# 调小可防止被恶意刷邮件,调大可让正常用户更频繁重发 +SEND_CODE_MAX_PER_EMAIL=5 + +# 同一 IP 每小时最多发送验证码次数,默认 10 +SEND_CODE_MAX_PER_IP=10 + +# 验证码发送限流窗口(秒),默认 3600 = 1 小时 +SEND_CODE_WINDOW_SECONDS=3600 + +# 验证码最多尝试次数,默认 5 +# 作用:同一邮箱在验证码有效期内错误尝试达到该次数后,验证码自动作废,需重新发送 +# 防止 6 位验证码被暴力枚举 +VERIFY_CODE_MAX_ATTEMPTS=5 + +# 验证码尝试限流窗口(秒),默认 600 = 10 分钟 +VERIFY_CODE_WINDOW_SECONDS=600 + +# 验证码有效期(分钟),默认 10 +EMAIL_CODE_TTL_MINUTES=10 + +# 同一邮箱重发验证码的最小间隔(秒),默认 60 +EMAIL_RESEND_INTERVAL_SECONDS=60 + +# 评论最大长度(字),默认 2000 +# 同时是接口层强制上限(超出返回错误),改小可减轻存储与展示压力 +COMMENT_MAX_LENGTH=2000 + +# 注册/重置密码的最小长度(位),默认 6 +PASSWORD_MIN_LENGTH=6 + +# ---------------- SSH 白名单自动更新(可选,强烈建议)---------------- + +# 背景:家庭宽带的公网 IP 经常变化,安全组 22 端口若只放行固定 IP, +# 换 IP 后就会把自己挡在门外。启用后,家庭电脑定时上报当前公网 IP, +# 服务器自动调用阿里云 API 更新安全组 22 端口白名单(只放行最新 IP)。 +# 原理:家庭端 -> https://域名/myip 获取公网 IP -> 变化时调用 +# POST /api/ipwatch/report -> 服务器更新安全组(先加新规则、后删旧规则)。 +# 说明:AccessKey 只放在服务器 .env,家庭端只配置下方 IPWATCH_SECRET 即可。 +# 完整配置步骤见 deploy/DEPLOY.md「日常运维」章节。 + +# 上报密钥(必填):家庭端 ipwatch.conf 里的 SECRET 必须与此相同 +# 作用:防止任意人调用上报接口刷白名单;建议用随机字符串并定期更换 +# 生成:python3 -c "import secrets; print(secrets.token_urlsafe(24))" +IPWATCH_SECRET= + +# 阿里云 AccessKey(RAM 子账号,仅授予该安全组的增删查权限即可) +# 作用:服务器调用阿里云 ECS API 更新安全组规则 +# 注意:不要用主账号密钥;建议定期在 RAM 控制台轮换 +ALIYUN_AK_ID= +ALIYUN_AK_SECRET= + +# 安全组 ID(必填):阿里云控制台「ECS -> 安全组 -> 基础信息」查看 +IPWATCH_SECURITY_GROUP_ID= + +# 阿里云地域 ID(必填):乌兰察布是 cn-wulanchabu,其它地域见控制台 +IPWATCH_REGION=cn-wulanchabu + +# 受保护的端口(默认 22 = SSH),一般无需修改 +IPWATCH_PORT=22 + +# 上报限流(秒):同一来源 IP 两次上报的最小间隔,默认 60 +# 作用:即使密钥泄露,攻击者每分钟也只能刷一条白名单 +# 调大(如 600):更安全;调小(如 10):换 IP 后恢复 SSH 更快 +IPWATCH_REPORT_INTERVAL_SECONDS=60 + + +# ---------------- JS→WASM 预编译(可选)---------------- + +# 总开关(默认 true):是否允许把上传项目的 .js 预编译为 .wasm +# 作用:仅在服务器生成编译产物与报告,不会执行任何上传代码; +# 浏览器演示页仍然运行原始 JS(Javy 产物面向服务端沙箱执行等场景) +# 注意:需先在服务器执行 bash deploy/install_javy.sh 安装编译工具 +WASM_COMPILE_ENABLED=true + +# Javy 编译工具路径(默认 /usr/local/bin/javy) +# 安装脚本:deploy/install_javy.sh(GitHub 下载约 14MB 单文件) +JAVY_PATH=/usr/local/bin/javy + +# 单个 JS 文件大小上限(MB),默认 3 +# 作用:超过该大小的 JS 跳过不编译,防止超大文件撑爆内存/磁盘 +# 调大(如 10):允许编译更大的文件;调小(如 1):更保守 +WASM_MAX_JS_SIZE_MB=3 + +# 单文件编译超时(秒),默认 60 +# 作用:防止 javy 编译卡死拖慢接口;超时自动终止并记录失败 +WASM_TIMEOUT_SECONDS=60 + +# 压缩包打包成单个 wasm 时,全部 JS 的总量上限(MB),默认 10 +# 作用:防止超大项目把内存/磁盘撑爆;超过则跳过打包并在报告中注明 +# 调大(如 30):允许打包更大的项目;调小(如 3):更保守 +WASM_BUNDLE_MAX_SIZE_MB=10 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89babf4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# 环境配置(含密钥,禁止提交) +.env + +# Python +__pycache__/ +*.py[cod] + +# 数据库与上传文件 +backend/blog.db +uploads/ + +# 系统文件 +.DS_Store +Thumbs.db +# IP 白名单自动更新(家庭端运行数据,含密钥,禁止提交) +deploy/ipwatch/ipwatch.conf +deploy/ipwatch/lastip.txt +deploy/ipwatch/ipwatch.log diff --git a/.idea/MyBlog.iml b/.idea/MyBlog.iml new file mode 100644 index 0000000..8b8c395 --- /dev/null +++ b/.idea/MyBlog.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..c2b387a --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..2875f91 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + 1786717740321 + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..653722f --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +# 孤竹居士的个人博客 + +部署在阿里云 Ubuntu 上的个人全栈博客系统:原生前端 SPA + FastAPI + SQLite。 + +## 技术架构 + +| 层 | 技术 | +| --- | --- | +| 服务器 | Ubuntu Linux + Nginx | +| 前端 | 原生 HTML + CSS + JavaScript(SPA,History API 路由) | +| 后端 | Python FastAPI(仅监听 127.0.0.1:8080) | +| 数据库 | SQLite + SQLAlchemy ORM | +| 认证 | JWT Token(bcrypt 存储密码哈希) | +| 文件 | Nginx 静态访问 uploads 目录 | + +- Nginx:80(HTTP)/ 443(HTTPS,待配置);`/api/*` 反代到 `127.0.0.1:8080`;`/uploads/*` 静态映射。 +- 端口:22 SSH、80 HTTP、443 HTTPS、25565 Minecraft(独立运行,不占用博客端口)。 + +## 目录结构 + +```text +blog/ +├── frontend/ # 前端源码(原生 SPA,ES Modules 拆分) +│ ├── index.html +│ ├── style.css +│ ├── main.js # 入口:初始化 + 全局事件 +│ ├── api.js # 网络层:api() / uploadFile() +│ ├── auth.js # 登录 / 注册 / 会话 +│ ├── router.js # 路由与页面壳(导航栏、用户区) +│ ├── article.js # 文章列表 / 详情 / 分区 / 简介视图 +│ ├── comment.js # 评论与回复 +│ ├── friend.js # 好友申请 +│ ├── manage.js # 博主管理面板(发文 / 编辑 / 简介设置) +│ ├── markdown.js # Markdown 轻量渲染(含 URL 白名单) +│ ├── state.js # 全局常量与状态 +│ └── utils.js # DOM / 日期 / 弹窗 / Toast 工具 +├── backend/ +│ ├── main.py # FastAPI 实例 + 路由注册 + 统一错误格式 +│ ├── database.py # SQLite 连接与初始化 +│ ├── models.py # ORM 模型(User/Article/Comment/Like/Friend/EmailCode) +│ ├── schemas.py # Pydantic 请求/响应模型 +│ ├── auth.py # bcrypt + JWT + 当前用户依赖 +│ ├── security.py # 邮箱校验、IP 提取、内存限流器 +│ ├── email.py # SMTP 验证码发送 +│ ├── seed.py # 博主账号初始化(python -m backend.seed) +│ └── routers/ # 业务路由(user/article/comment/like/friend/email/password/upload) +├── uploads/ +│ ├── avatar/ # 头像 +│ ├── article/ # 文章图片(封面/插图) +│ └── project/ # 项目文件(zip) +├── .env # 环境配置(已被 .gitignore 忽略,勿提交) +├── .env.example # 环境变量模板(全中文注释) +├── requirements.txt # Python 依赖 +└── README.md +``` + +## 快速开始(本地开发) + +1. 安装依赖:`pip install -r requirements.txt` +2. 配置环境:复制 `.env.example` 为 `.env`,填写 `JWT_SECRET`、`BLOGGER_*`、`SMTP_*`。 +3. 初始化数据库与博主:`python -m backend.seed` +4. 启动后端:`uvicorn backend.main:app --host 127.0.0.1 --port 8080` +5. 启动前端:任意静态服务器指向 `frontend/`,并将 `/api/*` 反代到 `127.0.0.1:8080`、`/uploads/*` 映射到 `uploads/`(本地开发可借助 Nginx 或简单代理脚本实现)。 + +> 提示:前端为 SPA,任意路径(如 `/article/1`)都应回退到 `index.html`。 + +## 权限与角色 + +| 角色 | 权限 | +| --- | --- | +| visitor(游客) | 查看公开文章、申请好友(好友文章仅显示标题与封面) | +| friend(好友) | 查看公开与好友文章、评论(含回复)、点赞 | +| blogger(博主) | 全部权限:发布/编辑/删除文章、审批好友申请、上传图片与项目、修改简介与头像 | + +## API 一览(统一前缀 `/api`,统一响应 `{success, data, message}`) + +**认证与资料** +- `POST /api/register` 注册(邮箱/用户名/密码,bcrypt 存储) +- `POST /api/login` 登录(JWT;失败限流) +- `GET /api/user/level` 当前角色 +- `GET /api/user/me` 当前用户资料 +- `GET /api/user/blogger` 博主公开资料(简介页) +- `PUT /api/user/profile` 更新头像/简介 + +**文章** +- `POST /api/article/add` 发布(仅博主) +- `GET /api/article/list?page=&page_size=` 列表(分页;好友文章对游客仅标题+封面) +- `GET /api/article/{id}` 详情(好友文章对游客锁定正文) +- `PUT /api/article/{id}` 编辑(仅博主) +- `DELETE /api/article/{id}` 删除(仅博主,级联评论/点赞) + +**评论 / 点赞 / 好友** +- `POST /api/comment/add` 评论或回复(`parent_id` 可选;仅好友/博主) +- `GET /api/comment/list?article_id=` 评论列表 +- `POST /api/like/add` 点赞(好友/博主,不可重复) +- `GET /api/like/list?article_id=` 点赞列表 +- `POST /api/friend/apply` 申请好友 / `GET /api/friend/status` 好友状态 +- `GET /api/friend/applications` 申请列表(仅博主) +- `POST /api/friend/{id}/approve|reject` 审批(仅博主) + +**邮箱验证码 / 密码** +- `POST /api/email/send-code` 发送验证码(邮箱+IP 限流) +- `POST /api/email/verify-code` 校验验证码(尝试次数限制) +- `POST /api/password/forgot` 忘记密码 / `POST /api/password/reset` 重置密码 + +**上传** +- `POST /api/upload/avatar` 头像(jpg/png/webp,≤2MB) +- `POST /api/upload/article` 文章图片(仅博主,≤5MB) +- `POST /api/upload/project` 项目文件(仅博主,zip,≤50MB) + +## 安全说明 + +- 密码 bcrypt 哈希存储,禁止明文;localStorage 仅保存 token 与用户名。 +- JWT_SECRET 缺失时后端拒绝启动(fail-fast),禁止硬编码弱密钥。 +- 登录、验证码发送/校验均有内存限流(阈值可在 `.env` 调整)。 +- 上传文件:白名单扩展名 + 魔数校验 + 随机文件名,禁止执行用户上传内容。 +- 前端 Markdown 渲染带 URL 协议白名单(`javascript:`/`data:` 等被拦截)+ CSP 响应头/标签(纵深防御)。 +- 后端仅监听 127.0.0.1:8080,禁止公网直连。 +- 注册接口对“邮箱 / 用户名已占用”返回统一提示,且先核验验证码再查唯一性(防枚举);注册失败会作废本次验证码,需重新发送后再试。 + +## 部署(阿里云) + +> 详细的 Nginx 站点配置与 systemd 服务文件将在后续任务中补充(当前仅给出要点)。 + +1. 上传项目到 `/var/www/blog`,安装依赖:`pip install -r requirements.txt`。 +2. 配置 `.env`(强随机 `JWT_SECRET`、博主账号、SMTP、安全限流)。 +3. `python -m backend.seed` 初始化数据库与博主。 +4. Nginx:80 端口托管 `frontend/` 静态文件;`/api/*` 反代 `127.0.0.1:8080`;`/uploads/*` 映射 `uploads/`;SPA 回退 `index.html`。 +5. systemd 守护 `uvicorn`(开机自启、崩溃重启)。 +6. 安全组仅开放 22 / 80 / 443 / 25565。 + +## 数据库升级说明(仅旧库需要) + +全新部署无需迁移:`python -m backend.seed` 会自动按最新结构建表。 + +若从旧版本升级(服务器上已有 `backend/blog.db`),需先补齐新字段再重启服务: + +```bash +# 服务器(Ubuntu)执行;正式升级前建议先备份 backend/blog.db 与 uploads/ +cd /var/www/blog +python3 - <<'EOF' +import sqlite3, datetime +con = sqlite3.connect("backend/blog.db") +con.execute("ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0") +con.execute("ALTER TABLE friends ADD COLUMN created_time DATETIME") +con.execute("ALTER TABLE articles ADD COLUMN category VARCHAR(30) NOT NULL DEFAULT 'life'") +con.execute("ALTER TABLE users ADD COLUMN avatar_updated_time DATETIME") +now = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) # 与后端 utcnow() 一致:无时区 UTC +con.execute("UPDATE friends SET created_time=? WHERE created_time IS NULL", (now,)) +con.commit() +con.close() +print("数据库升级完成") +EOF +``` + +本地开发(Windows)若仅为调试数据,可省略迁移:删除 `backend/blog.db` 后重新执行 `python -m backend.seed` 即可。 + +字段说明: +- `users.token_version`:JWT 令牌版本号。重置密码后版本自增,该用户所有旧令牌立即失效(登录状态吊销)。 +- `friends.created_time`:好友申请创建时间,用于博主审批列表展示申请时间。 diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..21ecd45 --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +"""MyBlog 后端包。""" diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..85a668d --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,97 @@ +""" +用户认证模块。 + +职责: +- bcrypt 密码哈希与校验(禁止明文保存密码) +- JWT 令牌的生成与解析(载荷包含用户 id、角色、过期时间) +- FastAPI 依赖 get_current_user:从请求头解析当前登录用户 +""" + +import os +from datetime import datetime, timedelta, timezone + +import bcrypt +import jwt +from dotenv import load_dotenv +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session + +from .database import PROJECT_ROOT, get_db +from .models import User + +# 加载项目根目录下的 .env,确保 JWT_SECRET 可用(重复调用无副作用) +load_dotenv(PROJECT_ROOT / ".env") + +# JWT 签名密钥:必须在 .env 中配置。 +# 缺失时直接报错(fail-fast),禁止回退到硬编码默认密钥,避免生产环境使用弱密钥。 +JWT_SECRET = os.getenv("JWT_SECRET", "").strip() +if not JWT_SECRET: + raise RuntimeError( + "缺少 JWT_SECRET 环境变量:请在项目根目录 .env 中配置强随机密钥后重试" + ) + +JWT_ALGORITHM = "HS256" + +# 令牌默认有效期:7 天,可通过 .env 的 JWT_EXPIRE_MINUTES 覆盖 +JWT_EXPIRE_MINUTES = int(os.getenv("JWT_EXPIRE_MINUTES") or str(7 * 24 * 60)) + +# HTTP Bearer 令牌认证方案(auto_error=False:缺失令牌时不自动报错,便于统一错误返回格式) +bearer_scheme = HTTPBearer(auto_error=False) + + +def hash_password(password: str) -> str: + """使用 bcrypt 生成密码哈希,返回可直接存入数据库的字符串。""" + salt = bcrypt.gensalt() + return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8") + + +def verify_password(password: str, password_hash: str) -> bool: + """校验明文密码与 bcrypt 哈希是否匹配。""" + return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8")) + + +def create_access_token(user: User) -> str: + """生成 JWT 令牌:载荷包含用户 id、角色与过期时间。""" + now = datetime.now(timezone.utc) + payload = { + "sub": str(user.id), # 用户 id(subject) + "role": user.role, + "ver": user.token_version, # 令牌版本(重置密码后旧令牌失效) + "iat": now, # 签发时间 + "exp": now + timedelta(minutes=JWT_EXPIRE_MINUTES), # 过期时间 + } + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + + +def decode_token(token: str) -> dict: + """解析并校验 JWT 令牌,返回载荷;令牌无效或已过期时抛出 401。""" + try: + return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + except jwt.PyJWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="登录状态无效或已过期,请重新登录", + ) + + +def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), + db: Session = Depends(get_db), +) -> User: + """FastAPI 依赖:从 Authorization: Bearer 解析并返回当前用户。""" + if credentials is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录,请先登录") + payload = decode_token(credentials.credentials) + user_id = payload.get("sub") + try: + user_id = int(user_id) + except (TypeError, ValueError): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="令牌无效") + user = db.query(User).filter(User.id == user_id).first() + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在") + # 令牌版本校验:重置密码后 token_version 自增,旧令牌立即失效(吊销机制) + if (payload.get("ver") or 0) != (user.token_version or 0): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录状态已失效,请重新登录") + return user \ No newline at end of file diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..a3f340e --- /dev/null +++ b/backend/database.py @@ -0,0 +1,61 @@ +""" +数据库连接与初始化模块。 + +职责: +- 建立 SQLite + SQLAlchemy 连接(引擎) +- 提供会话工厂 SessionLocal 与 FastAPI 依赖 get_db +- 提供数据库初始化函数 init_db() +""" + +import os +from pathlib import Path + +from dotenv import load_dotenv +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker + +# 定位 backend 目录与项目根目录(与 backend 同级) +BACKEND_DIR = Path(__file__).resolve().parent +PROJECT_ROOT = BACKEND_DIR.parent + +# 读取项目根目录下的 .env 配置文件(若存在) +load_dotenv(PROJECT_ROOT / ".env") + +# 数据库地址:默认使用 backend 目录下的 SQLite 文件,可通过 .env 的 DATABASE_URL 覆盖(留空视为使用默认值) +DATABASE_URL = os.getenv("DATABASE_URL") or f"sqlite:///{(BACKEND_DIR / 'blog.db').as_posix()}" + +# 创建 SQLAlchemy 引擎 +# SQLite 需关闭 check_same_thread:FastAPI 多线程处理请求时会跨线程复用会话 +engine = create_engine( + DATABASE_URL, + connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}, +) + +# 会话工厂:每个请求创建独立会话,避免线程安全问题 +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# 所有 ORM 模型的公共声明基类 +Base = declarative_base() + + +def get_db(): + """FastAPI 依赖注入:提供数据库会话,请求结束后自动关闭。""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db() -> None: + """初始化数据库:创建所有已注册 ORM 模型对应的数据表。""" + # 延迟导入模型,确保全部模型注册到 Base.metadata 后再建表 + from . import models # noqa: F401 + + Base.metadata.create_all(bind=engine) + + +if __name__ == "__main__": + # 支持直接运行:python -m backend.database + init_db() + print(f"数据库初始化完成:{DATABASE_URL}") \ No newline at end of file diff --git a/backend/email.py b/backend/email.py new file mode 100644 index 0000000..3d3b11d --- /dev/null +++ b/backend/email.py @@ -0,0 +1,55 @@ +""" +邮件发送模块。 + +通过 SMTP 发送验证码邮件,配置项从 .env 读取: +- SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_AUTH_CODE / EMAIL_FROM +163 邮箱:smtp.163.com,端口 465(SSL),密码处填写授权码。 +""" + +import os +import smtplib +from email.header import Header +from email.mime.text import MIMEText +from email.utils import formataddr + +from dotenv import load_dotenv + +from .database import PROJECT_ROOT + +# 加载项目根目录下的 .env(重复调用无副作用) +load_dotenv(PROJECT_ROOT / ".env") + +SMTP_HOST = os.getenv("SMTP_HOST", "smtp.163.com") +SMTP_PORT = int(os.getenv("SMTP_PORT") or "465") +SMTP_USER = os.getenv("SMTP_USER", "") +SMTP_AUTH_CODE = os.getenv("SMTP_AUTH_CODE", "") +EMAIL_FROM = os.getenv("EMAIL_FROM", SMTP_USER) + + +def send_verification_code(to_email: str, code: str) -> None: + """向指定邮箱发送验证码邮件;发送失败时抛出异常由调用方处理。""" + if not SMTP_USER or not SMTP_AUTH_CODE: + raise RuntimeError("SMTP 未配置:请在 .env 中设置 SMTP_USER / SMTP_AUTH_CODE") + + subject = "博客 - 邮箱验证码" + body = ( + f"您好,\n\n" + f"您的验证码是:{code}\n" + f"验证码 10 分钟内有效,请勿泄露给他人。\n\n" + f"如非本人操作,请忽略本邮件。" + ) + message = MIMEText(body, "plain", "utf-8") + message["Subject"] = Header(subject, "utf-8") + message["From"] = formataddr(("MyBlog", EMAIL_FROM)) + message["To"] = to_email + + if SMTP_PORT == 465: + server = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=15) + else: + server = smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=15) + server.starttls() + try: + server.login(SMTP_USER, SMTP_AUTH_CODE) + server.sendmail(EMAIL_FROM, [to_email], message.as_string()) + finally: + server.quit() \ No newline at end of file diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..4e8eb5c --- /dev/null +++ b/backend/main.py @@ -0,0 +1,47 @@ +""" +FastAPI 应用入口。 + +严格遵守全局架构,本文件只负责: +1. 创建 FastAPI 实例 +2. 注册路由(router) +3. 统一全局异常返回格式({success, data, message}) +""" + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + +from .routers import api_router +from .schemas import UnifiedResponse + +# 创建 FastAPI 实例 +app = FastAPI( + title="MyBlog API", + description="个人博客系统后端 API(FastAPI + SQLite + JWT)", + version="0.1.0", +) + + +def _unified_error(status_code: int, message: str) -> JSONResponse: + """构造符合全局统一格式的错误响应。""" + return JSONResponse( + status_code=status_code, + content=UnifiedResponse(success=False, data=None, message=message).model_dump(), + ) + + +@app.exception_handler(StarletteHTTPException) +async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse: + """统一 HTTP 异常(业务错误)的返回格式。""" + return _unified_error(exc.status_code, str(exc.detail)) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: + """统一请求参数校验错误的返回格式。""" + return _unified_error(422, "请求参数校验失败") + + +# 注册路由:后续任务在 routers 包中实现各业务路由后统一挂载 +app.include_router(api_router) \ No newline at end of file diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..65c6833 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,174 @@ +""" +数据库 ORM 模型模块。 + +定义全局架构中的五个核心模型,以及邮箱验证码模型: +- User:用户(visitor / friend / blogger) +- Article:文章(public / friend 两种可见性) +- Comment:评论(支持回复,parent_id 指向父评论) +- Like:点赞 +- Friend:好友申请 +- EmailCode:邮箱验证码(一次性、带过期时间) +""" + +from datetime import datetime, timezone + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import relationship + +from .database import Base + +# ---------- 常量定义 ---------- + +# 用户角色(全局架构:visitor / friend / blogger) +ROLE_VISITOR = "visitor" +ROLE_FRIEND = "friend" +ROLE_BLOGGER = "blogger" + +# 文章可见性(全局架构:public / friend) +VISIBILITY_PUBLIC = "public" +VISIBILITY_FRIEND = "friend" + +# 文章分区(life 生活 / study 学习) +ARTICLE_CATEGORY_LIFE = "life" +ARTICLE_CATEGORY_STUDY = "study" + +# 项目类型(L0 静态托管 / GitHub 外链) +PROJECT_TYPE_STATIC = "static" +PROJECT_TYPE_LINK = "link" + +# 好友申请状态 +FRIEND_STATUS_PENDING = "pending" +FRIEND_STATUS_ACCEPTED = "accepted" +FRIEND_STATUS_REJECTED = "rejected" + +# 邮箱验证码用途 +PURPOSE_REGISTER = "register" +PURPOSE_RESET = "reset" + + +def utcnow() -> datetime: + """返回当前 UTC 时间(无时区),作为各模型时间字段的默认值。""" + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class User(Base): + """用户模型:对应权限系统的 visitor / friend / blogger 三种角色。""" + + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True, comment="用户 ID") + email = Column(String(255), unique=True, index=True, nullable=False, comment="邮箱,唯一") + username = Column(String(50), unique=True, index=True, nullable=False, comment="用户名,唯一") + password_hash = Column(String(255), nullable=False, comment="bcrypt 密码哈希,禁止保存明文") + role = Column(String(20), nullable=False, default=ROLE_VISITOR, comment="角色") + avatar = Column(String(500), nullable=True, comment="头像图片路径(uploads/avatar/)") + avatar_updated_time = Column(DateTime, nullable=True, comment="头像最近一次更换时间(配合更换频率限制)") + bio = Column(Text, nullable=True, comment="个人简介(我的简介页面展示)") + token_version = Column(Integer, nullable=False, default=0, comment="令牌版本号(重置密码时自增,使旧 JWT 立即失效)") + created_time = Column(DateTime, nullable=False, default=utcnow, comment="创建时间") + + # 关联关系:删除用户时级联删除其文章、评论、点赞与好友记录 + articles = relationship("Article", back_populates="author", cascade="all, delete-orphan") + comments = relationship("Comment", back_populates="user", cascade="all, delete-orphan") + likes = relationship("Like", back_populates="user", cascade="all, delete-orphan") + friends = relationship("Friend", back_populates="user", cascade="all, delete-orphan") + + +class Article(Base): + """文章模型:Markdown 正文,支持 public / friend 两种可见性。""" + + __tablename__ = "articles" + + id = Column(Integer, primary_key=True, index=True, comment="文章 ID") + title = Column(String(200), nullable=False, comment="文章标题") + content = Column(Text, nullable=False, comment="Markdown 格式正文") + cover = Column(String(500), nullable=True, comment="文章封面图片路径(uploads/article/)") + visibility = Column(String(20), nullable=False, default=VISIBILITY_PUBLIC, comment="可见性") + category = Column(String(30), nullable=False, default=ARTICLE_CATEGORY_LIFE, index=True, comment="分区:life 生活 / study 学习") + author_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True, comment="作者 ID") + created_time = Column(DateTime, nullable=False, default=utcnow, comment="创建时间") + + author = relationship("User", back_populates="articles") + comments = relationship("Comment", back_populates="article", cascade="all, delete-orphan") + likes = relationship("Like", back_populates="article", cascade="all, delete-orphan") + + +class Project(Base): + """项目模型:L0 静态托管(static,自动在线演示)或 GitHub 外链(link)。""" + + __tablename__ = "projects" + + id = Column(Integer, primary_key=True, index=True, comment="项目 ID") + name = Column(String(100), nullable=False, comment="项目名称") + description = Column(Text, nullable=True, comment="项目简介") + tech = Column(String(500), nullable=True, comment="技术标签(逗号分隔)") + project_type = Column(String(20), nullable=False, default=PROJECT_TYPE_STATIC, comment="项目类型:static 静态托管 / link 外链") + visibility = Column(String(20), nullable=False, default=VISIBILITY_PUBLIC, comment="可见性:public 公开 / friend 好友") + demo_url = Column(String(500), nullable=True, comment="在线运行地址(static 为站内演示地址,link 为 GitHub 链接)") + download_url = Column(String(500), nullable=True, comment="下载文件地址(uploads/project/)") + github_url = Column(String(500), nullable=True, comment="GitHub 仓库链接(link 类型必填)") + created_time = Column(DateTime, nullable=False, default=utcnow, comment="创建时间") + updated_time = Column(DateTime, nullable=False, default=utcnow, onupdate=utcnow, comment="更新时间") + + +class Comment(Base): + """评论模型:好友及博主可对文章发表评论,支持回复(parent_id)。""" + + __tablename__ = "comments" + + id = Column(Integer, primary_key=True, index=True, comment="评论 ID") + article_id = Column(Integer, ForeignKey("articles.id"), nullable=False, index=True, comment="文章 ID") + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True, comment="评论者 ID") + parent_id = Column(Integer, ForeignKey("comments.id"), nullable=True, index=True, comment="父评论 ID(回复时填写,顶层评论为空)") + content = Column(Text, nullable=False, comment="评论内容") + created_time = Column(DateTime, nullable=False, default=utcnow, comment="创建时间") + + article = relationship("Article", back_populates="comments") + user = relationship("User", back_populates="comments") + parent = relationship("Comment", remote_side=[id], back_populates="replies") + replies = relationship("Comment", back_populates="parent") + + +class Like(Base): + """点赞模型:同一用户对同一文章只能点赞一次。""" + + __tablename__ = "likes" + + id = Column(Integer, primary_key=True, index=True, comment="点赞记录 ID") + article_id = Column(Integer, ForeignKey("articles.id"), nullable=False, index=True, comment="文章 ID") + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True, comment="点赞者 ID") + + # 唯一约束:防止同一用户重复点赞同一文章 + __table_args__ = ( + UniqueConstraint("article_id", "user_id", name="uq_like_article_user"), + ) + + article = relationship("Article", back_populates="likes") + user = relationship("User", back_populates="likes") + + +class Friend(Base): + """好友申请模型:按已确认架构字段(id / user_id / status)实现。""" + + __tablename__ = "friends" + + id = Column(Integer, primary_key=True, index=True, comment="好友记录 ID") + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True, comment="申请人 ID") + status = Column(String(20), nullable=False, default=FRIEND_STATUS_PENDING, comment="申请状态") + created_time = Column(DateTime, nullable=False, default=utcnow, comment="申请创建时间") + + user = relationship("User", back_populates="friends") + + +class EmailCode(Base): + """邮箱验证码模型:一次性验证码,带过期时间。""" + + __tablename__ = "email_codes" + + id = Column(Integer, primary_key=True, index=True, comment="验证码记录 ID") + email = Column(String(255), index=True, nullable=False, comment="目标邮箱") + code = Column(String(10), nullable=False, comment="验证码") + purpose = Column(String(30), nullable=False, comment="用途:register / reset") + expires_at = Column(DateTime, nullable=False, comment="过期时间") + used = Column(Boolean, nullable=False, default=False, comment="是否已使用") + created_time = Column(DateTime, nullable=False, default=utcnow, comment="创建时间") \ No newline at end of file diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..c0b9e5c --- /dev/null +++ b/backend/routers/__init__.py @@ -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) diff --git a/backend/routers/article.py b/backend/routers/article.py new file mode 100644 index 0000000..31352d3 --- /dev/null +++ b/backend/routers/article.py @@ -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="删除成功") \ No newline at end of file diff --git a/backend/routers/comment.py b/backend/routers/comment.py new file mode 100644 index 0000000..6f4e058 --- /dev/null +++ b/backend/routers/comment.py @@ -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="获取成功") \ No newline at end of file diff --git a/backend/routers/deps.py b/backend/routers/deps.py new file mode 100644 index 0000000..4d57568 --- /dev/null +++ b/backend/routers/deps.py @@ -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) \ No newline at end of file diff --git a/backend/routers/email.py b/backend/routers/email.py new file mode 100644 index 0000000..c1334bd --- /dev/null +++ b/backend/routers/email.py @@ -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="验证码校验通过", + ) \ No newline at end of file diff --git a/backend/routers/friend.py b/backend/routers/friend.py new file mode 100644 index 0000000..068cb94 --- /dev/null +++ b/backend/routers/friend.py @@ -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="已拒绝好友申请", + ) \ No newline at end of file diff --git a/backend/routers/ipwatch.py b/backend/routers/ipwatch.py new file mode 100644 index 0000000..9903ce1 --- /dev/null +++ b/backend/routers/ipwatch.py @@ -0,0 +1,264 @@ +# -*- coding: utf-8 -*- +""" +SSH 白名单自动更新路由(阿里云安全组)。 + +背景:家庭宽带公网 IP 经常变化,安全组 22 端口若只放行固定 IP, +换 IP 后就会把自己挡在门外。本路由让家庭电脑定时上报当前公网 IP, +服务器调用阿里云 ECS API 自动更新安全组 22 端口的入方向白名单。 + +接口(统一响应格式 {success, data, message}): +- POST /api/ipwatch/report 上报当前公网 IP,自动增删安全组 22 端口规则 +- GET /api/ipwatch/status 查看服务配置状态(不含任何密钥) + +安全设计: +- AccessKey 只存在于服务器 .env,家庭端只持有 IPWATCH_SECRET 上报密钥 +- 上报密钥用 hmac 恒定时间比较,并按来源 IP 限流(默认 60 秒一次) +- 更新顺序“先加新规则、后删旧规则”,任何一步失败都不会让 SSH 完全断连 +- 只操作“tcp 22/22 且来源为单个 IP(/32)”的规则,绝不碰 0.0.0.0/0 等其它规则 +""" + +import base64 +import hashlib +import hmac +import ipaddress +import json +import logging +import os +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel, Field + +from ..schemas import UnifiedResponse +from ..security import RateLimiter, get_client_ip + +router = APIRouter(prefix="/api/ipwatch", tags=["ipwatch"]) + +# 日志走 uvicorn 的 logger,方便 journalctl 查看 +logger = logging.getLogger("uvicorn.error") + + +# ---------- 环境变量配置(服务器 .env,见 .env.example 中文注释) ---------- + +IPWATCH_SECRET = (os.getenv("IPWATCH_SECRET") or "").strip() +ALIYUN_AK_ID = (os.getenv("ALIYUN_AK_ID") or "").strip() +ALIYUN_AK_SECRET = (os.getenv("ALIYUN_AK_SECRET") or "").strip() +SECURITY_GROUP_ID = (os.getenv("IPWATCH_SECURITY_GROUP_ID") or "").strip() +IPWATCH_REGION = (os.getenv("IPWATCH_REGION") or "cn-hangzhou").strip() +IPWATCH_PORT = int(os.getenv("IPWATCH_PORT") or "22") + +# 同一来源 IP 两次上报的最小间隔(秒),防止密钥泄露后被刷白名单 +try: + _report_interval = int(os.getenv("IPWATCH_REPORT_INTERVAL_SECONDS") or "60") +except ValueError: + _report_interval = 60 +report_limiter = RateLimiter(1, max(_report_interval, 1)) + + +class IpWatchReport(BaseModel): + """上报请求体:家庭端上传密钥与当前公网 IP。""" + + secret: str = Field(min_length=1, max_length=256) + ip: str = Field(min_length=7, max_length=45) + + +def _aliyun_call(action: str, params: dict) -> dict: + """调用阿里云 ECS RPC API(HMAC-SHA1 签名,仅用 Python 标准库)。 + + Aliyun OpenAPI 签名规则:对全部参数按 key 排序后拼接, + 再用 AccessKeySecret 做 HMAC-SHA1,最后 BASE64 得到 Signature。 + """ + query = { + "AccessKeyId": ALIYUN_AK_ID, + "Action": action, + "Format": "JSON", + "SignatureMethod": "HMAC-SHA1", + "SignatureNonce": uuid.uuid4().hex, # 每次请求唯一,防止重放 + "SignatureVersion": "1.0", + "Timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "Version": "2014-05-26", + "RegionId": IPWATCH_REGION, + } + query.update(params or {}) + + def _enc(value: str) -> str: + """RFC3986 百分号编码(阿里云要求保留 -_.~ 三个字符)。""" + return urllib.parse.quote(str(value), safe="-_.~") + + canonical = "&".join(f"{_enc(k)}={_enc(v)}" for k, v in sorted(query.items())) + string_to_sign = "GET&%2F&" + _enc(canonical) + signature = base64.b64encode( + hmac.new((ALIYUN_AK_SECRET + "&").encode(), string_to_sign.encode(), hashlib.sha1).digest() + ).decode() + url = f"https://ecs.{IPWATCH_REGION}.aliyuncs.com/?{canonical}&Signature={_enc(signature)}" + + request = urllib.request.Request(url, headers={"User-Agent": "MyBlog-ipwatch/1.0"}) + try: + with urllib.request.urlopen(request, timeout=20) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as exc: + # 阿里云返回错误时,响应体里是 JSON 格式的错误信息 + try: + return json.loads(exc.read().decode()) + except Exception: + return {"Code": "HTTP_ERROR", "Message": f"阿里云接口返回 HTTP {exc.code}"} + except Exception as exc: + return {"Code": "NETWORK_ERROR", "Message": f"无法连接阿里云接口:{exc}"} + + +def _fetch_ingress_rules() -> list: + """读取安全组全部入方向规则,失败时抛出 502。""" + resp = _aliyun_call( + "DescribeSecurityGroupAttribute", + {"SecurityGroupId": SECURITY_GROUP_ID, "Direction": "ingress"}, + ) + if resp.get("Code"): + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"读取安全组规则失败:{resp.get('Message') or resp.get('Code')}", + ) + return resp.get("Permissions", {}).get("Permission", []) + + +def _host_ip(cidr: str) -> str: + """把安全组来源归一化为纯 IP;非 IPv4 单 IP(/32) 返回空字符串。 + + 阿里云返回的 SourceCidrIp 可能是 "1.2.3.4" 或 "1.2.3.4/32", + 本函数只认 IPv4 的 /32 单 IP,其它(0.0.0.0/0、IPv6 等)一律忽略, + 防止误删用户手动配置的放行规则。 + """ + if not cidr: + return "" + value = cidr.strip() + if "/" not in value: + try: + ip = ipaddress.ip_address(value) + except ValueError: + return "" + return str(ip) if ip.version == 4 else "" + try: + network = ipaddress.ip_network(value, strict=False) + except ValueError: + return "" + if network.version != 4 or network.prefixlen != 32: + return "" + return str(network.network_address) + + +@router.get("/status", response_model=UnifiedResponse) +def status_info() -> UnifiedResponse: + """查看服务配置状态(不返回任何密钥),便于部署后排查。""" + data = { + "configured": bool(IPWATCH_SECRET and ALIYUN_AK_ID and ALIYUN_AK_SECRET and SECURITY_GROUP_ID), + "region": IPWATCH_REGION, + "security_group_id": SECURITY_GROUP_ID, + "port": IPWATCH_PORT, + "report_interval_seconds": report_limiter.window_seconds, + } + return UnifiedResponse(success=True, data=data, message="获取成功") + + +@router.post("/report", response_model=UnifiedResponse) +def report(payload: IpWatchReport, request: Request) -> UnifiedResponse: + """上报当前公网 IP:先加新白名单、再删旧白名单,全程不会锁死 SSH。""" + # 1. 服务配置检查(未配置时直接拒绝,避免密钥为空被绕过) + if not IPWATCH_SECRET: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="IP 白名单服务未配置") + if not (ALIYUN_AK_ID and ALIYUN_AK_SECRET): + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="阿里云密钥未配置") + if not SECURITY_GROUP_ID: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="安全组未配置") + + # 2. 上报密钥校验(恒定时间比较,防止时序侧信道) + if not hmac.compare_digest(payload.secret.encode("utf-8"), IPWATCH_SECRET.encode("utf-8")): + logger.warning("ipwatch: 上报密钥错误,来源 %s", get_client_ip(request)) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="上报密钥错误") + + # 3. 按来源 IP 限流,防止密钥泄露后被无限刷白名单 + client_ip = get_client_ip(request) + if report_limiter.is_blocked(client_ip): + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="上报过于频繁,请稍后再试") + + # 4. IP 格式校验(仅支持 IPv4) + ip_text = payload.ip.strip() + try: + ip_obj = ipaddress.ip_address(ip_text) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="IP 地址格式不正确") + if ip_obj.version != 4: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 IPv4 地址") + new_ip = str(ip_obj) + new_cidr = f"{new_ip}/32" + target_range = f"{IPWATCH_PORT}/{IPWATCH_PORT}" + + # 5. 读取当前 22 端口规则,判断是否需要更新 + rules = _fetch_ingress_rules() + port_rules = [ + r + for r in rules + if str(r.get("IpProtocol", "")).lower() == "tcp" and r.get("PortRange") == target_range + ] + already_authorized = any(_host_ip(r.get("SourceCidrIp")) == new_ip for r in port_rules) + if already_authorized: + return UnifiedResponse( + success=True, + data={"changed": False, "ip": new_ip, "port": IPWATCH_PORT}, + message="IP 已在白名单中,无需更新", + ) + + # 6. 先添加新 IP 规则(成功后才进入删除阶段,避免 SSH 断连) + add_resp = _aliyun_call( + "AuthorizeSecurityGroup", + { + "SecurityGroupId": SECURITY_GROUP_ID, + "IpProtocol": "tcp", + "PortRange": target_range, + "SourceCidrIp": new_cidr, + "Policy": "accept", + "Description": "MyBlog IPWatch 自动白名单", + }, + ) + if add_resp.get("Code"): + logger.error("ipwatch: 添加白名单失败 %s -> %s", new_cidr, add_resp) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"添加白名单失败:{add_resp.get('Message') or add_resp.get('Code')}", + ) + + # 7. 删除旧的单 IP 规则(只删 22 端口 /32 来源,绝不动 0.0.0.0/0 等规则) + removed = [] + for rule in port_rules: + old_ip = _host_ip(rule.get("SourceCidrIp")) + if not old_ip or old_ip == new_ip: + continue + # 阿里云对旧规则的存储格式不统一(有的带 /32,有的不带), + # 依次尝试两种格式;任一成功即视为删除完成 + revoke_ok = False + for cidr_candidate in (f"{old_ip}/32", old_ip): + revoke_resp = _aliyun_call( + "RevokeSecurityGroup", + { + "SecurityGroupId": SECURITY_GROUP_ID, + "IpProtocol": "tcp", + "PortRange": target_range, + "SourceCidrIp": cidr_candidate, + "Policy": "accept", + }, + ) + if not revoke_resp.get("Code"): + revoke_ok = True + break + if revoke_ok: + removed.append(old_ip) + else: + # 新规则已生效,旧规则删除失败只是残留,不影响 SSH 可用性 + logger.warning("ipwatch: 删除旧规则失败 %s -> %s", old_ip, revoke_resp) + + report_limiter.hit(client_ip) + data = {"changed": True, "ip": new_ip, "port": IPWATCH_PORT, "removed": removed} + logger.info("ipwatch: 白名单更新完成 新IP=%s 删除=%s", new_ip, removed) + return UnifiedResponse(success=True, data=data, message="白名单更新成功") diff --git a/backend/routers/like.py b/backend/routers/like.py new file mode 100644 index 0000000..339a9b2 --- /dev/null +++ b/backend/routers/like.py @@ -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="获取成功") \ No newline at end of file diff --git a/backend/routers/password.py b/backend/routers/password.py new file mode 100644 index 0000000..24c780a --- /dev/null +++ b/backend/routers/password.py @@ -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="密码重置成功,请使用新密码登录") \ No newline at end of file diff --git a/backend/routers/project.py b/backend/routers/project.py new file mode 100644 index 0000000..ddd80ee --- /dev/null +++ b/backend/routers/project.py @@ -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="项目已删除") diff --git a/backend/routers/upload.py b/backend/routers/upload.py new file mode 100644 index 0000000..ef480f3 --- /dev/null +++ b/backend/routers/upload.py @@ -0,0 +1,193 @@ +""" +文件上传路由。 + +接口(统一响应格式 {success, data, message}): +- POST /api/upload/avatar 头像上传(登录用户;jpg/png/webp,最大 2MB;成功后自动保存到头像字段) +- POST /api/upload/article 文章图片上传(仅博主;jpg/png/webp,最大 6MB) +- POST /api/upload/project 项目文件上传(仅博主;zip,最大 50MB) +- POST /api/upload/doc 文章文档上传(仅博主;doc/docx,默认最大 20MB;仅用于生活/学习分区) + +安全规则: +- 随机文件名(uuid),不使用用户原始文件名,杜绝路径穿越 +- 校验扩展名 + 文件魔数(内容真实性),防止伪装类型 +- 文件仅作为静态资源由 Nginx 通过 /uploads/ 访问,禁止执行 +""" + +import os +import uuid +from datetime import timedelta +from pathlib import Path + +from fastapi import APIRouter, Depends, Form, HTTPException, UploadFile, status +from sqlalchemy.orm import Session + +from ..auth import get_current_user +from ..database import PROJECT_ROOT, get_db +from ..models import ( + ARTICLE_CATEGORY_LIFE, + ARTICLE_CATEGORY_STUDY, + ROLE_BLOGGER, + ROLE_FRIEND, + User, + utcnow, +) +from ..schemas import UnifiedResponse + +router = APIRouter(prefix="/api/upload", tags=["upload"]) + +# 上传根目录(可通过 .env 的 UPLOADS_DIR 覆盖;部署时 Nginx 将 /uploads/ 静态映射到该目录) +UPLOADS_ROOT = Path(os.getenv("UPLOADS_DIR") or str(PROJECT_ROOT / "uploads")) + +# 允许的扩展名(值仅用于展示) +ALLOWED_IMAGES = {"jpg", "jpeg", "png", "webp"} +ALLOWED_VIDEO = {"mp4", "webm"} +ALLOWED_PROJECT = {"zip"} +ALLOWED_DOC = {"doc", "docx"} + +# 大小上限(字节):头像上限可通过 .env 的 AVATAR_MAX_SIZE_MB 调整(默认 4MB) +MAX_AVATAR_SIZE = int(os.getenv("AVATAR_MAX_SIZE_MB", "4")) * 1024 * 1024 +# 文章图片(封面/正文插图)大小上限:可通过 .env 的 ARTICLE_IMAGE_MAX_SIZE_MB 调整(默认 6MB) +MAX_IMAGE_SIZE = int(os.getenv("ARTICLE_IMAGE_MAX_SIZE_MB", "6")) * 1024 * 1024 +MAX_VIDEO_SIZE = 100 * 1024 * 1024 # 文章视频 100MB +MAX_PROJECT_SIZE = 50 * 1024 * 1024 # 项目文件 50MB +# 文章文档(doc/docx)大小上限:可通过 .env 的 DOC_MAX_SIZE_MB 调整(默认 20MB) +MAX_DOC_SIZE = int(os.getenv("DOC_MAX_SIZE_MB", "20")) * 1024 * 1024 + + +def check_magic_bytes(content: bytes, ext: str) -> bool: + """校验文件魔数,防止伪造扩展名。""" + if ext in ("jpg", "jpeg"): + return content[:3] == b"\xff\xd8\xff" + if ext == "png": + return content[:8] == b"\x89PNG\r\n\x1a\n" + if ext == "webp": + return content[:4] == b"RIFF" and content[8:12] == b"WEBP" + if ext == "mp4": + return content[4:8] == b"ftyp" + if ext == "webm": + return content[:4] == b"\x1aE\xdf\xa3" + if ext == "zip": + return content[:4] in (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08") + if ext == "doc": + # Word 97-2003:OLE 复合文档魔数 + return content[:8] == b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + if ext == "docx": + # Word 2007+:本质是 zip 压缩包 + return content[:4] in (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08") + return False + + +def save_upload(file: UploadFile, subdir: str, allowed: set, max_size: int) -> dict: + """校验并保存上传文件,返回 URL 等元信息。""" + original_name = file.filename or "" + ext = original_name.rsplit(".", 1)[-1].lower() if "." in original_name else "" + if ext not in allowed: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"不支持的文件类型(允许:{' / '.join(sorted(allowed))})", + ) + + # 一次读入,超出上限直接拒绝(+1 用于判断是否超限) + content = file.file.read(max_size + 1) + if len(content) > max_size: + raise HTTPException(status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="文件大小超出限制") + + if not check_magic_bytes(content, ext): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件内容与扩展名不符") + + # 随机文件名,避免覆盖与路径穿越 + saved_name = f"{uuid.uuid4().hex}.{ext}" + target_dir = UPLOADS_ROOT / subdir + target_dir.mkdir(parents=True, exist_ok=True) + (target_dir / saved_name).write_bytes(content) + + return { + "url": f"/uploads/{subdir}/{saved_name}", + "filename": original_name, + "size": len(content), + } + + +@router.post("/avatar", response_model=UnifiedResponse) +def upload_avatar( + file: UploadFile, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> UnifiedResponse: + """头像上传:仅好友或博主可上传(访客除注册外禁止一切上传);成功后自动更新头像字段。 + 更换频率限制:同一账号两次更换之间至少间隔 AVATAR_CHANGE_INTERVAL_HOURS 小时(默认 24)。 + """ + if current_user.role not in (ROLE_FRIEND, ROLE_BLOGGER): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="访客不能上传头像") + + # 更换冷却:距离上次更换不足 N 小时则拒绝,并提示剩余等待时间 + interval_hours = int(os.getenv("AVATAR_CHANGE_INTERVAL_HOURS", "24")) + if current_user.avatar_updated_time is not None: + elapsed = utcnow() - current_user.avatar_updated_time + if elapsed < timedelta(hours=interval_hours): + remaining = timedelta(hours=interval_hours) - elapsed + hours = int(remaining.total_seconds() // 3600) + minutes = int((remaining.total_seconds() % 3600) // 60) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=f"头像更换过于频繁,请约 {hours} 小时 {minutes} 分钟后再试", + ) + + data = save_upload(file, "avatar", ALLOWED_IMAGES, MAX_AVATAR_SIZE) + current_user.avatar = data["url"] + current_user.avatar_updated_time = utcnow() + db.commit() + return UnifiedResponse(success=True, data=data, message="头像上传成功") + + +@router.post("/article", response_model=UnifiedResponse) +def upload_article_image( + file: UploadFile, + current_user: User = Depends(get_current_user), +) -> UnifiedResponse: + """文章图片上传:仅博主可上传(用于封面与正文插图)。""" + if current_user.role != ROLE_BLOGGER: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以上传文章图片") + data = save_upload(file, "article", ALLOWED_IMAGES, MAX_IMAGE_SIZE) + return UnifiedResponse(success=True, data=data, message="图片上传成功") + + +@router.post("/video", response_model=UnifiedResponse) +def upload_article_video( + file: UploadFile, + current_user: User = Depends(get_current_user), +) -> UnifiedResponse: + """文章视频上传:仅博主可上传(mp4 / webm,最大 100MB),供正文插入视频。""" + if current_user.role != ROLE_BLOGGER: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以上传文章视频") + data = save_upload(file, "article", ALLOWED_VIDEO, MAX_VIDEO_SIZE) + return UnifiedResponse(success=True, data=data, message="视频上传成功") + + +@router.post("/project", response_model=UnifiedResponse) +def upload_project_file( + file: UploadFile, + current_user: User = Depends(get_current_user), +) -> UnifiedResponse: + """项目文件上传:仅博主可上传,支持 zip 压缩包(供下载项目使用)。""" + if current_user.role != ROLE_BLOGGER: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以上传项目文件") + data = save_upload(file, "project", ALLOWED_PROJECT, MAX_PROJECT_SIZE) + return UnifiedResponse(success=True, data=data, message="项目文件上传成功") + +@router.post("/doc", response_model=UnifiedResponse) +def upload_article_doc( + file: UploadFile, + category: str = Form(...), + current_user: User = Depends(get_current_user), +) -> UnifiedResponse: + """文章文档上传(doc / docx):仅博主可上传,且只能用于“我的生活 / 我的学习”分区的文章。""" + if current_user.role != ROLE_BLOGGER: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以上传文档") + if category not in (ARTICLE_CATEGORY_LIFE, ARTICLE_CATEGORY_STUDY): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="文档只能用于生活或学习分区的文章", + ) + data = save_upload(file, "article", ALLOWED_DOC, MAX_DOC_SIZE) + return UnifiedResponse(success=True, data=data, message="文档上传成功") diff --git a/backend/routers/user.py b/backend/routers/user.py new file mode 100644 index 0000000..1e95907 --- /dev/null +++ b/backend/routers/user.py @@ -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="资料已更新") \ No newline at end of file diff --git a/backend/routers/wasm.py b/backend/routers/wasm.py new file mode 100644 index 0000000..3230304 --- /dev/null +++ b/backend/routers/wasm.py @@ -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="") diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..3fa07cd --- /dev/null +++ b/backend/schemas.py @@ -0,0 +1,222 @@ +""" +Pydantic 数据校验模型(Schemas)。 + +职责: +- 定义前后端 JSON 通信所需的数据结构 +- 统一定义全局响应格式 {success, data, message} +""" + +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class UnifiedResponse(BaseModel): + """全局统一响应格式:所有接口返回该结构。""" + + success: bool = True + data: Optional[Any] = None + message: str = "" + + +class UserCreate(BaseModel): + """创建用户(注册)请求体:需携带邮箱验证码;角色固定为 visitor(后端强制)。""" + + email: str = Field(max_length=255) + username: str = Field(min_length=2, max_length=50) + password: str = Field(max_length=128) + code: str = Field(default="", max_length=10, description="邮箱验证码(注册前通过 /api/email/send-code 获取)") + + +class UserOut(BaseModel): + """用户信息输出(不含密码哈希)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + email: str + username: str + role: str + created_time: datetime + + +class LoginRequest(BaseModel): + """登录请求体。""" + + email: str + password: str + + +class TokenOut(BaseModel): + """登录成功返回的 JWT 令牌与用户基础信息。""" + + token: str + user_id: int + username: str + role: str + + +class UserLevelOut(BaseModel): + """用户权限级别(角色)输出。""" + + user_id: int + role: str + + +class ProfileOut(BaseModel): + """个人资料输出(头像、简介等)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + email: str + username: str + role: str + avatar: Optional[str] = None + bio: Optional[str] = None + created_time: datetime + + +class BloggerProfileOut(BaseModel): + """博主公开资料输出(我的简介页面使用)。""" + + username: str + avatar: Optional[str] = None + bio: Optional[str] = None + role: str + + +class ProfileUpdate(BaseModel): + """更新个人资料请求体(头像 / 简介,均可选)。""" + + avatar: Optional[str] = Field(default=None, max_length=500) + bio: Optional[str] = Field(default=None, max_length=2000) + + +class SendCodeRequest(BaseModel): + """发送邮箱验证码请求体。""" + + email: str + purpose: str + + +class VerifyCodeRequest(BaseModel): + """校验邮箱验证码请求体。""" + + email: str + code: str + purpose: str + + +class ForgotPasswordRequest(BaseModel): + """忘记密码请求体。""" + + email: str + + +class ResetPasswordRequest(BaseModel): + """重置密码请求体。""" + + email: str + code: str + new_password: str = Field(max_length=128) + + +class ArticleCreate(BaseModel): + """创建文章请求体。""" + + title: str = Field(min_length=1, max_length=200) + content: str = Field(min_length=1, max_length=200000) + cover: Optional[str] = Field(default=None, max_length=500) + visibility: str = "public" + category: str = Field(default="life", description="分区:life 生活 / study 学习(取值由路由校验,统一 400 提示)") + + +class ArticleUpdate(BaseModel): + """更新文章请求体(字段均可选,仅更新传入的字段)。""" + + title: Optional[str] = Field(default=None, min_length=1, max_length=200) + content: Optional[str] = Field(default=None, min_length=1, max_length=200000) + cover: Optional[str] = Field(default=None, max_length=500) + visibility: Optional[str] = None + category: Optional[str] = None + + +class ArticleOut(BaseModel): + """文章信息输出。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + title: str + content: str + cover: Optional[str] = None + visibility: str + author_id: int + created_time: datetime + + +class CommentCreate(BaseModel): + """发表评论请求体(parent_id 用于回复,顶层评论不填)。""" + + article_id: int + content: str = Field(min_length=1, max_length=5000) + parent_id: Optional[int] = None + + +class CommentOut(BaseModel): + """评论信息输出。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + article_id: int + user_id: int + parent_id: Optional[int] = None + content: str + created_time: datetime + + +class ProjectOut(BaseModel): + """项目信息输出(在线演示 / GitHub 外链)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + description: Optional[str] = None + tech: Optional[str] = None + project_type: str + visibility: str + demo_url: Optional[str] = None + download_url: Optional[str] = None + github_url: Optional[str] = None + created_time: datetime + updated_time: datetime + + +class LikeCreate(BaseModel): + """点赞请求体。""" + + article_id: int + + +class LikeOut(BaseModel): + """点赞信息输出。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + article_id: int + user_id: int + + +class FriendOut(BaseModel): + """好友申请信息输出。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + user_id: int + status: str \ No newline at end of file diff --git a/backend/security.py b/backend/security.py new file mode 100644 index 0000000..b03bf33 --- /dev/null +++ b/backend/security.py @@ -0,0 +1,119 @@ +""" +安全工具模块。 + +职责: +- 邮箱格式校验(统一规则,各路由复用) +- 客户端 IP 提取(兼容 Nginx X-Forwarded-For) +- 内存限流器:登录失败锁定、验证码发送/校验频率控制 + +说明: +- 本项目为单进程 uvicorn 部署,内存限流器即可满足需求; + 若未来改为多进程部署,可将实现替换为 Redis 等共享存储。 +""" + +import os +import re +import time +from collections import defaultdict, deque +from threading import Lock +from typing import Deque, Dict + +from fastapi import Request + + +def _env_int(name: str, default: int) -> int: + """读取整数型环境变量,缺失或非法时返回默认值。""" + try: + return int(os.getenv(name, "").strip() or default) + except ValueError: + return default + + +# ---------- 邮箱校验 ---------- + +EMAIL_PATTERN = r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$" + + +def is_valid_email(email: str) -> bool: + """校验邮箱基本格式,返回是否合法。""" + return bool(re.match(EMAIL_PATTERN, email or "")) + + +# ---------- 客户端 IP ---------- + +def get_client_ip(request: Request) -> str: + """获取客户端 IP:优先信任 Nginx 写入的 X-Forwarded-For,否则取直连地址。""" + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +# ---------- 内存限流器 ---------- + +class RateLimiter: + """滑动窗口限流器:记录 key 在窗口内的访问次数,超过阈值判定为阻塞。""" + + def __init__(self, max_events: int, window_seconds: int): + self.max_events = max_events + self.window_seconds = window_seconds + self._records: Dict[str, Deque[float]] = defaultdict(deque) + self._lock = Lock() + + def _prune(self, key: str, now: float) -> None: + """清理窗口外的历史记录,避免内存无限增长。""" + queue = self._records[key] + while queue and now - queue[0] > self.window_seconds: + queue.popleft() + + def hit(self, key: str) -> int: + """记录一次事件,返回窗口内的总次数。""" + now = time.monotonic() + with self._lock: + self._prune(key, now) + self._records[key].append(now) + return len(self._records[key]) + + def is_blocked(self, key: str) -> bool: + """判断当前是否已达阈值(阻塞)。""" + now = time.monotonic() + with self._lock: + self._prune(key, now) + return len(self._records[key]) >= self.max_events + + def reset(self, key: str) -> None: + """清空指定 key 的记录(如登录成功后重置失败计数)。""" + with self._lock: + self._records.pop(key, None) + + +# ---------- 限流策略(阈值与窗口,均可通过 .env 调整,默认值见下) ---------- + +# 登录失败:同一邮箱在窗口内失败达到上限后临时锁定,防止暴力破解博主账号 +LOGIN_MAX_FAILURES = _env_int("LOGIN_MAX_FAILURES", 5) +LOGIN_WINDOW_SECONDS = _env_int("LOGIN_WINDOW_SECONDS", 15 * 60) +login_failure_limiter = RateLimiter(LOGIN_MAX_FAILURES, LOGIN_WINDOW_SECONDS) + +# 验证码发送:同一邮箱每小时最多 N 封、同一 IP 每小时最多 N 封,防止被当作垃圾邮件中继 +SEND_CODE_MAX_PER_EMAIL = _env_int("SEND_CODE_MAX_PER_EMAIL", 5) +SEND_CODE_MAX_PER_IP = _env_int("SEND_CODE_MAX_PER_IP", 10) +SEND_CODE_WINDOW_SECONDS = _env_int("SEND_CODE_WINDOW_SECONDS", 60 * 60) +send_code_email_limiter = RateLimiter(SEND_CODE_MAX_PER_EMAIL, SEND_CODE_WINDOW_SECONDS) +send_code_ip_limiter = RateLimiter(SEND_CODE_MAX_PER_IP, SEND_CODE_WINDOW_SECONDS) + +# 验证码校验:同一邮箱在验证码有效期内错误尝试达到上限后作废验证码,防 6 位验证码被暴力枚举 +VERIFY_CODE_MAX_ATTEMPTS = _env_int("VERIFY_CODE_MAX_ATTEMPTS", 5) +VERIFY_CODE_WINDOW_SECONDS = _env_int("VERIFY_CODE_WINDOW_SECONDS", 10 * 60) +verify_code_limiter = RateLimiter(VERIFY_CODE_MAX_ATTEMPTS, VERIFY_CODE_WINDOW_SECONDS) +# 注册限流:同一 IP 每小时最多 3 次注册,防止批量注册垃圾账号(可通过 .env 调整) +REGISTER_MAX_PER_IP = _env_int("REGISTER_MAX_PER_IP", 3) +REGISTER_WINDOW_SECONDS = _env_int("REGISTER_WINDOW_SECONDS", 60 * 60) +register_ip_limiter = RateLimiter(REGISTER_MAX_PER_IP, REGISTER_WINDOW_SECONDS) + +# 登录限流(IP 维度):同一 IP 在登录窗口内最多尝试 30 次,配合邮箱维度防分布式爆破 +LOGIN_MAX_PER_IP = _env_int("LOGIN_MAX_PER_IP", 30) +login_ip_limiter = RateLimiter(LOGIN_MAX_PER_IP, LOGIN_WINDOW_SECONDS) + +# 写操作限流(评论/点赞):同一 IP 每分钟最多 30 次,防止好友账号刷屏 +ACTION_MAX_PER_MINUTE = _env_int("ACTION_MAX_PER_MINUTE", 30) +action_limiter = RateLimiter(ACTION_MAX_PER_MINUTE, 60) diff --git a/backend/seed.py b/backend/seed.py new file mode 100644 index 0000000..341001a --- /dev/null +++ b/backend/seed.py @@ -0,0 +1,50 @@ +""" +博主账号初始化脚本。 + +用法: +1. 在 .env 中配置 BLOGGER_USERNAME / BLOGGER_EMAIL / BLOGGER_PASSWORD +2. 运行:python -m backend.seed + +脚本会创建全局唯一博主账号(bcrypt 哈希存储密码);已存在则跳过。 +""" + +import os + +from .auth import hash_password +from .database import PROJECT_ROOT, SessionLocal, init_db +from .models import ROLE_BLOGGER, User + + +def seed_blogger() -> None: + """创建唯一博主账号(已存在则跳过)。""" + username = os.getenv("BLOGGER_USERNAME", "").strip() + email = os.getenv("BLOGGER_EMAIL", "").strip() + password = os.getenv("BLOGGER_PASSWORD", "") + + if not (username and email and password): + raise SystemExit("请在 .env 中配置 BLOGGER_USERNAME / BLOGGER_EMAIL / BLOGGER_PASSWORD") + if "@" not in email: + raise SystemExit("BLOGGER_EMAIL 格式不正确") + + init_db() + db = SessionLocal() + try: + exists = db.query(User).filter((User.email == email) | (User.username == username)).first() + if exists is not None: + print(f"博主账号已存在(id={exists.id}, username={exists.username}),跳过创建") + return + blogger = User( + email=email, + username=username, + password_hash=hash_password(password), + role=ROLE_BLOGGER, + ) + db.add(blogger) + db.commit() + print(f"博主账号创建成功(id={blogger.id}, username={blogger.username})") + finally: + db.close() + + +if __name__ == "__main__": + seed_blogger() \ No newline at end of file diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/wasm_builder.py b/backend/services/wasm_builder.py new file mode 100644 index 0000000..304d816 --- /dev/null +++ b/backend/services/wasm_builder.py @@ -0,0 +1,309 @@ +"""JS → WASM 预编译服务(基于 Javy / QuickJS)。 + +职责: +- 调用 Javy 把项目演示目录里的 .js 文件预编译为 .wasm 模块 +- 把项目“下载压缩包”里的全部 JS 合并打包为单个 .wasm(与 zip 同目录,可下载) +- 产物与 report.json 编译报告一起输出,只做“编译”,绝不执行任何上传代码 + +使用场景说明: +- 浏览器里的 JS 无法真正“预编译”成 WASM 后代替原脚本运行: + 浏览器自带 V8 JIT 运行时编译,本身就很快;而 Javy 产物是 + “QuickJS 解释器 + 字节码”,更慢且无法操作 DOM,因此演示页 + 仍然运行原始 JS 文件。 +- Javy 产物真正有用的场景是“服务端沙箱执行”:把不可信的 JS + 关进 WASM 沙箱里运行,隔离文件/网络/系统访问,而不是直接在 + 服务器上执行原 JS。 +""" + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import time +import zipfile +from datetime import datetime, timezone +from pathlib import Path + +from ..database import PROJECT_ROOT, SessionLocal +from ..models import Project + +# 上传根目录(与 routers/project.py 解析方式一致,可通过 .env 的 UPLOADS_DIR 覆盖) +UPLOADS_ROOT = Path(os.getenv("UPLOADS_DIR") or str(PROJECT_ROOT / "uploads")) +DEMOS_DIR = UPLOADS_ROOT / "demos" + +# Javy 编译工具路径(可通过 .env 的 JAVY_PATH 覆盖) +JAVY_PATH = os.getenv("JAVY_PATH") or "/usr/local/bin/javy" + +# 功能总开关:false 时接口直接返回“未启用”,便于临时关闭 +WASM_COMPILE_ENABLED = os.getenv("WASM_COMPILE_ENABLED", "true").strip().lower() in ( + "1", "true", "yes", "on", +) + +# 单个 JS 文件大小上限(MB):防止超大文件把内存/磁盘撑爆 +WASM_MAX_JS_SIZE_MB = float(os.getenv("WASM_MAX_JS_SIZE_MB") or "3") + +# 压缩包打包成单个 wasm 时,全部 JS 的总量上限(MB) +WASM_BUNDLE_MAX_MB = float(os.getenv("WASM_BUNDLE_MAX_SIZE_MB") or "10") + +# 单文件编译超时(秒):防止 javy 卡死拖慢接口 +WASM_TIMEOUT_SECONDS = int(os.getenv("WASM_TIMEOUT_SECONDS") or "60") + +# 编译时跳过的目录(避免把编译产物/第三方依赖再编译一遍) +_SKIP_DIRS = {"wasm", "node_modules", ".git", "__pycache__"} + + +def javy_version() -> str: + """返回 Javy 版本号;工具未安装时返回空字符串。""" + try: + result = subprocess.run( + [JAVY_PATH, "--version"], capture_output=True, text=True, timeout=10, + ) + return (result.stdout or result.stderr).strip() + except (OSError, subprocess.SubprocessError): + return "" + + +def _resolve_zip_path(download_url: str) -> Path: + """把数据库里的下载地址(/uploads/project/xxx.zip)解析为服务器文件路径。""" + if not download_url: + return Path() + relative = download_url.lstrip("/") + if relative.startswith("uploads/"): + relative = relative[len("uploads/"):] + return UPLOADS_ROOT / relative + + +def find_js_files(project_dir: Path): + """收集项目目录中的 .js 文件(跳过 wasm/ 等产物目录),按路径排序。""" + files = [] + for path in sorted(project_dir.rglob("*.js")): + rel = path.relative_to(project_dir) + if any(part in _SKIP_DIRS for part in rel.parts): + continue + files.append(path) + return files + + +def _wasm_output_path(project_dir: Path, src: Path) -> Path: + """生成 wasm 输出路径:wasm/<去斜杠相对路径>.wasm,避免子目录重名冲突。""" + rel = src.relative_to(project_dir) + if len(rel.parts) > 1: + slug = "_".join(rel.parts[:-1]) + "_" + rel.stem + else: + slug = rel.stem + return project_dir / "wasm" / f"{slug}.wasm" + + +def compile_one(src: Path, out: Path) -> dict: + """调用 javy 编译单个 JS 文件,返回该文件的编译结果记录。""" + item = { + "file": src.name, + "status": "failed", + "message": "", + "js_size": src.stat().st_size, + "wasm_size": 0, + "compile_ms": 0, + "output": None, + } + out.parent.mkdir(parents=True, exist_ok=True) + start = time.monotonic() + try: + result = subprocess.run( + [JAVY_PATH, "build", str(src), "-o", str(out)], + capture_output=True, text=True, timeout=WASM_TIMEOUT_SECONDS, + ) + item["compile_ms"] = int((time.monotonic() - start) * 1000) + if result.returncode != 0: + item["message"] = (result.stderr or result.stdout or "编译失败").strip()[:300] + return item + if out.is_file(): + item["status"] = "ok" + item["wasm_size"] = out.stat().st_size + item["output"] = str(out.relative_to(UPLOADS_ROOT)).replace("\\", "/") + except subprocess.TimeoutExpired: + item["message"] = f"编译超时(超过 {WASM_TIMEOUT_SECONDS} 秒)" + except OSError as exc: + item["message"] = f"无法运行 Javy:{exc}" + return item + + +def compile_zip_bundle(zip_path: Path) -> dict: + """把压缩包里的全部 JS 合并打包为单个 wasm(与 zip 同目录、同名 .wasm)。 + + 说明:zip 不是 JS,不能直接编译;这里把 zip 内的所有 .js 按文件名排序 + 拼接成一个临时 bundle.js,再交给 Javy 编译,产物即“压缩包的 wasm 版本”。 + """ + item = { + "file": zip_path.name, + "status": "skipped", + "message": "", + "js_size": 0, + "wasm_size": 0, + "compile_ms": 0, + "output": None, + "source_zip": f"project/{zip_path.name}", + } + if not zip_path.is_file(): + item["message"] = "下载压缩包不存在,跳过" + return item + + max_file_bytes = int(WASM_MAX_JS_SIZE_MB * 1024 * 1024) + max_total_bytes = int(WASM_BUNDLE_MAX_MB * 1024 * 1024) + total_bytes = 0 + parts = [] + try: + with zipfile.ZipFile(zip_path) as zf: + for info in zf.infolist(): + if info.is_dir(): + continue + name = info.filename.replace("\\", "/") + if not name.lower().endswith(".js"): + continue + if name.startswith("__MACOSX/") or any(seg in _SKIP_DIRS for seg in name.split("/")): + continue + if info.file_size > max_file_bytes: + item["message"] = f"zip 内含超限文件 {name}(超过 {WASM_MAX_JS_SIZE_MB:g}MB),跳过打包" + return item + total_bytes += info.file_size + if total_bytes > max_total_bytes: + item["message"] = f"zip 内 JS 总量超过 {WASM_BUNDLE_MAX_MB:g}MB,跳过打包" + return item + parts.append((name, zf.read(info))) + except zipfile.BadZipFile: + item["message"] = "压缩包损坏,跳过" + return item + + if not parts: + item["message"] = "压缩包内没有 JS 文件,跳过" + return item + + # 按文件名排序后合并(保持确定性;浏览器脚本间依赖全局变量,字母序通常可用) + parts.sort(key=lambda p: p[0]) + bundle_lines = [] + for name, data in parts: + bundle_lines.append(f"// ===== {name} =====") + bundle_lines.append(data.decode("utf-8", errors="replace")) + bundle_js = "\n".join(bundle_lines) + + # 写临时 bundle.js 编译,产物输出到 zip 同目录:{zip文件名}.wasm + out = zip_path.with_suffix(".wasm") + with tempfile.NamedTemporaryFile( + "w", suffix=".js", encoding="utf-8", delete=False, + ) as tmp: + tmp.write(bundle_js) + tmp_path = Path(tmp.name) + try: + result = compile_one(tmp_path, out) + item["status"] = result["status"] + item["message"] = result["message"] + item["js_size"] = total_bytes + item["wasm_size"] = result["wasm_size"] + item["compile_ms"] = result["compile_ms"] + item["output"] = result["output"] + if result["status"] == "ok": + item["file"] = f"{zip_path.name}(全部 JS 打包为单个 wasm)" + finally: + tmp_path.unlink(missing_ok=True) + return item + + +def compile_project(project_id: int) -> dict: + """编译指定项目(演示目录 JS + 下载压缩包打包),返回完整报告。""" + from fastapi import HTTPException, status # 局部导入,避免循环依赖 + + db = SessionLocal() + try: + project = db.get(Project, project_id) + finally: + db.close() + if project is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") + if not WASM_COMPILE_ENABLED: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="WASM 预编译功能未启用(WASM_COMPILE_ENABLED=false)", + ) + if not (shutil.which(JAVY_PATH) or Path(JAVY_PATH).is_file()): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="服务器未安装 Javy 编译工具,请先执行 deploy/install_javy.sh", + ) + + project_dir = DEMOS_DIR / str(project_id) + items = [] + if project_dir.is_dir(): + files = find_js_files(project_dir) + max_bytes = int(WASM_MAX_JS_SIZE_MB * 1024 * 1024) + for src in files: + if src.stat().st_size > max_bytes: + items.append({ + "file": src.name, + "status": "skipped", + "message": f"文件超过 {WASM_MAX_JS_SIZE_MB:g}MB 上限", + "js_size": src.stat().st_size, + "wasm_size": 0, + "compile_ms": 0, + "output": None, + }) + continue + out = _wasm_output_path(project_dir, src) + items.append(compile_one(src, out)) + else: + items.append({ + "file": "(无在线演示目录)", + "status": "skipped", + "message": "该项目没有在线演示目录,仅尝试打包下载压缩包", + "js_size": 0, + "wasm_size": 0, + "compile_ms": 0, + "output": None, + }) + + zip_bundle = compile_zip_bundle(_resolve_zip_path(project.download_url)) + + ok_items = [i for i in items if i["status"] == "ok"] + report = { + "project_id": project_id, + "created_time": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"), + "javy_version": javy_version(), + "summary": { + "js_files": len(items), + "success": len(ok_items), + "failed": sum(1 for i in items if i["status"] == "failed"), + "skipped": sum(1 for i in items if i["status"] == "skipped"), + "total_js_bytes": sum(i["js_size"] for i in items), + "total_wasm_bytes": sum(i["wasm_size"] for i in ok_items), + "zip_bundle": zip_bundle["status"], + "zip_bundle_wasm_bytes": zip_bundle["wasm_size"], + }, + "items": items, + "zip_bundle": zip_bundle, + } + if project_dir.is_dir(): + report_dir = project_dir / "wasm" + report_dir.mkdir(parents=True, exist_ok=True) + (report_dir / "report.json").write_text( + json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8", + ) + return report + + +def main() -> None: + """命令行入口:python -m backend.services.wasm_builder <项目ID>。""" + import argparse + + parser = argparse.ArgumentParser(description="把项目 JS 预编译为 WASM") + parser.add_argument("project_id", type=int, help="项目 ID") + args = parser.parse_args() + try: + report = compile_project(args.project_id) + except Exception as exc: # noqa: BLE001 - 命令行场景统一打印错误原因 + print(f"编译失败:{getattr(exc, 'detail', exc)}") + sys.exit(1) + print(json.dumps(report, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md new file mode 100644 index 0000000..56af106 --- /dev/null +++ b/deploy/DEPLOY.md @@ -0,0 +1,476 @@ +# MyBlog 部署指南(Ubuntu + Nginx + FastAPI) + +本指南覆盖:服务器初始化 → 上传项目 → 安装依赖 → 配置 Nginx / systemd → 开启 HTTPS → 验证上线。 +假设你的阿里云服务器为 **Ubuntu 22.04/24.04**,已有公网 IP 与可登录的账号(root 或 sudo 用户)。 + +> 架构速览:Nginx 提供前端静态文件与 `/uploads/`,并把 `/api/*` 反代到 +> `127.0.0.1:8080` 的 FastAPI(只监听本机,公网无法直连);后端使用 SQLite,数据在 +> `/var/www/blog/backend/blog.db`。 + +--- + +## 一、本地准备:确认要上传的内容 + +项目根目录(`D:\MyBlog`)需要上传: + +| 路径 | 说明 | 是否上传 | +| --- | --- | --- | +| `frontend/` | 前端全部文件(index.html / *.js / style.css) | ✅ | +| `backend/` | 后端代码(不含 `blog.db`、`__pycache__`) | ✅ | +| `uploads/` | 上传目录(可传已有文件,如头像 favicon.png) | ✅(可为空) | +| `requirements.txt` | Python 依赖清单 | ✅ | +| `.env.example` | 环境变量模板 | ✅ | +| `README.md` | 项目说明 | ✅ | +| `deploy/` | 部署文件(nginx.conf / myblog.service / 本指南) | ✅ | +| `.env` | 含密钥与邮箱授权码,**不要上传**,到服务器上手动创建 | ❌ | +| `backend/blog.db` | 本地测试数据库,**不要上传**,服务器上重新初始化 | ❌ | + +### 1.1 Windows 打包并上传(自带 tar 与 scp) + +```powershell +# 在项目目录 D:\MyBlog 下执行:打包(排除本地环境文件) +tar -czf myblog.tar.gz frontend backend uploads deploy requirements.txt .env.example README.md + +# 上传到服务器 /tmp(把 root@IP 换成你的实际账号和公网 IP) +scp myblog.tar.gz root@你的公网IP:/tmp/ +``` + +> 提示:Windows 10 1803+ 自带 `tar` / `scp`。也可用 WinSCP / Xftp 直接拖拽, +> 或使用 Git Bash / WSL 里的 `rsync -avz --exclude='.env' --exclude='backend/blog.db' ./ root@IP:/var/www/blog/`。 + +--- + +## 二、阿里云安全组开放端口 + +登录阿里云控制台 → ECS 实例 → 安全组 → 配置规则 → 入方向,**仅开放以下端口**: + +| 端口 | 用途 | 建议 | +| --- | --- | --- | +| 22 | SSH 远程管理 | 建议“指定源”只放行你的家庭/办公 IP | +| 80 | HTTP(Nginx) | 0.0.0.0/0 | +| 443 | HTTPS(Nginx) | 0.0.0.0/0(配合 certbot) | +| 25565 | Minecraft(独立服务,与博客无关) | 按需开放 | + +⚠️ **8080 端口千万不要开放**:FastAPI 只监听 `127.0.0.1:8080`,公网即使访问 8080 也会被拒绝; +如果安全组放行 8080 而 Nginx 代理配置有误,等于把后端裸奔在公网上。 + +--- + +## 三、服务器初始化 + +SSH 登录服务器后执行: + +```bash +# 1. 系统更新 + 安装 Nginx / Python3 / venv / pip +sudo apt update && sudo apt upgrade -y +sudo apt install -y nginx python3 python3-venv python3-pip + +# 2. 创建博客专用系统账号(禁止登录,只用于运行后端) +sudo useradd -r -m -s /usr/sbin/nologin blog + +# 3. 创建部署目录 +sudo mkdir -p /var/www/blog +sudo chown -R blog:blog /var/www/blog + +# 4. 解压上传的项目包(由 /tmp 解压到 /var/www/blog) +sudo mkdir -p /var/www/blog +cd /tmp && sudo tar -xzf myblog.tar.gz -C /var/www/blog/ +sudo chown -R blog:blog /var/www/blog + +# 5. 准备证书校验目录(Nginx 配置里用到) +sudo mkdir -p /var/www/blog/.well-known/acme-challenge +sudo chown -R blog:blog /var/www/blog/.well-known + +# 6. 确认前端文件就位(页面引用绝对路径 /main.js,root 必须指向 frontend) +ls /var/www/blog/frontend/index.html +``` + +--- + +## 四、配置 .env(服务器上手动创建) + +`.env` 含密钥与授权码,必须到服务器上创建,不要从本地直接上传: + +```bash +cd /var/www/blog +sudo -u blog cp .env.example .env +sudo -u blog nano .env # 或 vi +``` + +必填项(`nano` 里按 `Ctrl+O` 保存,`Ctrl+X` 退出): + +```ini +# 强随机密钥:在服务器上执行下面命令生成,然后粘贴进来 +# python3 -c "import secrets; print(secrets.token_urlsafe(48))" +JWT_SECRET=这里粘贴生成的随机密钥 + +# 博主账号(首次执行 python -m backend.seed 时创建) +BLOGGER_USERNAME=孤竹居士 +BLOGGER_EMAIL=guzhujushi2008@163.com +BLOGGER_PASSWORD=你的登录密码 + +# SMTP 邮箱验证码(163 邮箱 + 授权码) +SMTP_HOST=smtp.163.com +SMTP_PORT=465 +SMTP_USER=guzhujushi2008@163.com +SMTP_AUTH_CODE=你的163授权码 +EMAIL_FROM=guzhujushi2008@163.com +``` + +其余可选变量(限流、验证码、评论长度等)不填会使用默认值,说明见 `.env.example`。 +设置文件权限,防止他人读取密钥: + +```bash +sudo chmod 600 /var/www/blog/.env +``` + +--- + +## 五、安装依赖 + 初始化数据库 + +```bash +# 1. 创建虚拟环境并安装依赖(建议先换成国内 pip 镜像,速度更快) +cd /var/www/blog +sudo -u blog python3 -m venv venv +sudo -u blog venv/bin/pip install -r requirements.txt +# 可选加速:-i https://pypi.tuna.tsinghua.edu.cn/simple + +# 2. 初始化数据库表(自动创建 backend/blog.db) +sudo -u blog venv/bin/python -m backend.database + +# 3. 创建唯一博主账号(读取 .env 里的 BLOGGER_*,重复执行会跳过) +sudo -u blog venv/bin/python -m backend.seed + +# 4. 检查上传目录可写 +sudo -u blog mkdir -p uploads/avatar uploads/article uploads/project +sudo chown -R blog:blog /var/www/blog/uploads +``` + +--- + +## 六、部署 Nginx 配置 + +```bash +# 1. 复制配置文件(内容见 deploy/nginx.conf) +sudo cp /var/www/blog/deploy/nginx.conf /etc/nginx/sites-available/myblog + +# 2. 把 server_name 改成你的域名(或公网 IP) +sudo nano /etc/nginx/sites-available/myblog + +# 3. 启用站点(删除默认站点,避免冲突) +sudo rm -f /etc/nginx/sites-enabled/default +sudo ln -s /etc/nginx/sites-available/myblog /etc/nginx/sites-enabled/ + +# 4. 校验配置并重载 +sudo nginx -t +sudo systemctl reload nginx +``` + +--- + +## 七、部署 systemd 服务 + +```bash +# 1. 复制服务文件(内容见 deploy/myblog.service) +sudo cp /var/www/blog/deploy/myblog.service /etc/systemd/system/ + +# 2. 加载并启动 +sudo systemctl daemon-reload +sudo systemctl enable --now myblog + +# 3. 确认状态:Active: active (running) 表示成功 +sudo systemctl status myblog +``` + +**手动启动方式(调试用,生产请用 systemd)**: + +```bash +cd /var/www/blog +sudo -u blog /var/www/blog/venv/bin/uvicorn backend.main:app --host 127.0.0.1 --port 8080 +``` + +--- + +## 八、开启 HTTPS(Let's Encrypt,免费证书) + +```bash +# 1. 安装 certbot 的 Nginx 插件 +sudo apt install -y certbot python3-certbot-nginx + +# 2. 一键签发并自动改写 Nginx 配置(会自动加 443 监听与自动续期) +sudo certbot --nginx -d your-domain.com + +# 3. 证书自动续期(certbot 已内置定时任务,可验证) +sudo certbot renew --dry-run +``` + +HTTPS 配置完成后: +- 80 端口会 301 跳转到 443(certbot 自动处理) +- 记得把安全组 443 放行(见第二节) +- 建议把 `.env` 里的 `EMAIL_FROM` 等保持不变即可 + +--- + +## 九、上线验证清单 + +```bash +# 1. 后端接口是否通(应返回 {"success":true,...}) +curl http://127.0.0.1:8080/api/article/list + +# 2. 前端首页是否可访问(应返回 index.html) +curl -I http://127.0.0.1:8080/ # 本机访问后端正常(证明服务活着) +curl -I http://你的公网IP/ # 公网走 Nginx 访问首页 +# 公网直接访问 http://公网IP:8080 应超时/拒绝(8080 未放行且只监听本机) + +# 3. SPA 刷新是否正常(直接访问子路由应回退到 index.html) +curl -I http://你的公网IP/partition/4 + +# 4. 上传目录是否可访问(favicon 应为 200) +curl -I http://你的公网IP/uploads/avatar/favicon.png + +# 5. 查看后端日志是否有报错 +journalctl -u myblog -f + +# 6. 浏览器验证全流程: +# 打开首页 → 注册/登录 → 文章列表 → 文章详情 → 评论/点赞 → 好友申请 +# (博主)登录 → 发文管理 → 上传头像/封面/项目 → 审批好友申请 +``` + +--- + +## 十、常见问题 + +| 现象 | 排查方向 | +| --- | --- | +| 页面能打开但接口报错 | `journalctl -u myblog` 看后端日志;确认 `.env` 里 `JWT_SECRET` 已配置 | +| 上传图片 404 | `uploads/` 目录权限:`sudo chown -R blog:blog /var/www/blog/uploads` | +| 刷新子页面 404 | Nginx `try_files $uri $uri/ /index.html;` 是否在 `location /` 中 | +| 博主登录失败多次被锁 | 安全组限流策略(默认 5 次/15 分钟),等窗口过期或调大 `.env` 阈值 | +| 验证码发不出去 | 检查 `.env` 的 SMTP 配置与 163 授权码;服务器 465 端口出方向是否被云安全组限制 | +| 修改代码后不生效 | 后端:`sudo systemctl restart myblog`;前端:用 `deploy/update.ps1` 部署(自动换版本号,浏览器强制拉新文件);若仍异常,Ctrl+F5 强刷一次 | +| 8080 被公网扫到 | 确认 uvicorn 只监听 `127.0.0.1`,且安全组未放行 8080 | + +--- + +## 十一、日常运维 + +```bash +# 查看状态 / 重启 / 日志 +sudo systemctl status myblog +sudo systemctl restart myblog +journalctl -u myblog -n 100 + +# 备份数据库与上传文件(SQLite 单文件 + 目录) +sudo tar -czf backup_$(date +%F).tar.gz -C /var/www/blog backend/blog.db uploads + +# 升级代码流程:见下方「更新代码到服务器(增量同步 + 自动重启)」 +``` + +### 更新代码到服务器(增量同步 + 自动重启) + +修改完本地代码后,把改动同步到服务器并重启后端,**一条命令完成**: + +```bash +# 在本地 Git Bash / WSL 的项目目录执行(root 登录时无需 sudo) +rsync -avz \ + --exclude='.env' \ + --exclude='backend/blog.db' \ + --exclude='venv' \ + --exclude='__pycache__' \ + --exclude='uploads/' \ + ./ root@你的公网IP:/var/www/blog/ \ +&& ssh root@你的公网IP 'systemctl restart myblog' +``` + +命令做了什么: +- `rsync` 只把**本地改动过的文件**增量同步到服务器,未变化的自动跳过; + `&&` 表示同步成功后继续通过 SSH 执行重启 → 改完代码跑这一条,就是一次完整更新。 +- 只改前端(`frontend/`)时后端无需重启;重启约 1 秒、无副作用,想省心可每次都执行。 + +注意: +- **不要加 `--delete`**:会删除服务器上本地没有的文件(例如用户通过网页上传的图片)。 +- 排除 `uploads/`:它是服务器上的运行时数据(网页上传的文件只存在于服务器)。 + 若本地新增了静态资源(如头像 favicon)需要上传,去掉 `--exclude='uploads/'` 再执行。 +- 非 root 用户:把 `root@` 换成你的用户名,并先在服务器上配置该命令的免密 sudo: + +```bash +# 服务器上执行(username 换成你的登录用户) +sudo visudo -f /etc/sudoers.d/blog-update +# 在文件里加入下面一行后保存: +# username ALL=(ALL) NOPASSWD: /bin/systemctl restart myblog +``` + +之后本地执行: + +```bash +rsync -avz --exclude='.env' --exclude='backend/blog.db' --exclude='venv' --exclude='__pycache__' --exclude='uploads/' ./ username@你的公网IP:/var/www/blog/ && ssh username@你的公网IP 'sudo systemctl restart myblog' +``` + +验证更新是否生效: + +```bash +ssh root@你的公网IP 'systemctl status myblog' +curl -s http://你的公网IP/api/article/list # 应返回 {"success":true,...} +``` + + +### 一键更新脚本(推荐,Windows 上直接用) + +项目自带两个脚本,不需要 rsync / Git Bash,Windows 打开 PowerShell 即可: + +| 文件 | 作用 | +| --- | --- | +| `deploy/update.ps1` | 本地一键脚本:打包代码 -> 上传 -> 触发服务器更新 | +| `deploy/update_server.sh` | 服务器端脚本:备份 -> 解压 -> 数据库迁移 -> 重启 -> 验证 | +| `deploy/deploy.conf` | 配置服务器地址与域名(`SERVER=` / `DOMAIN=`) | + +用法: + +```powershell +# 在项目根目录 D:\MyBlog 下执行 +powershell -ExecutionPolicy Bypass -File .\deploy\update.ps1 + +# 或指定服务器(覆盖 deploy.conf) +powershell -ExecutionPolicy Bypass -File .\deploy\update.ps1 -Server root@8.145.36.108 + +# 需要把本地 uploads/ 一并同步时(首次部署 / 新增静态资源) +powershell -ExecutionPolicy Bypass -File .\deploy\update.ps1 -IncludeUploads +``` + +脚本会做的事(与上面 rsync 方案等价且更省心): +- 打包时自动排除 `.env`、`backend/blog.db`、`__pycache__`、`venv`,默认也排除 `uploads/` +- 每次更新前在服务器上自动备份到 `/root/blog-backups/`(含数据库与上传文件),可回滚 +- 自动执行幂等数据库迁移(补齐 `articles.category` 等新列),并重启 `myblog` 服务 +- 结束后自动验证后端接口与线上 HTTPS 是否正常 + +### 前端缓存自动清理(更新后无需手动 Ctrl+F5) + +页面引用的 `style.css` / `main.js` 已带版本号 `?v=__VERSION__`: + +- 每次执行 `deploy/update.ps1` 部署时,服务器端会自动把 `__VERSION__` 替换成**当前时间戳** +- 版本号一变化,浏览器就会重新下载最新 JS/CSS,旧缓存自动失效,无需手动清理 +- Nginx 同时对前端文件返回 `Cache-Control: no-cache`(每次重新校验),双保险 +- 如需手动验证:`curl -s https://你的域名/ | grep main.js` 应能看到带版本号的引用 + +### SSH 白名单自动更新(家庭公网 IP 变化不锁死) + +家庭宽带公网 IP 经常变化,若安全组 22 端口只放行固定 IP,换 IP 后 SSH 会被自己挡在门外。 +本项目提供“家庭端定时上报 + 服务器端自动更新安全组”的完整方案,文件均在 `deploy/ipwatch/`。 + +``` +家庭电脑 report.ps1(每 30 分钟)---> 博客 /myip 获取当前公网 IP + | IP 有变化时 + └--> POST /api/ipwatch/report(携带密钥) + 服务器调用阿里云 ECS API:先新增新 IP 规则,成功后再删除旧规则(绝不锁死 SSH) +``` + +**服务器端配置(一次性):** + +1. 在服务器 `.env` 中追加以下变量(AccessKey 只放服务器,不要发给家庭端): + +```ini +# 生成随机上报密钥:python3 -c "import secrets; print(secrets.token_urlsafe(24))" +IPWATCH_SECRET=这里粘贴随机密钥 +ALIYUN_AK_ID=你的RAM子账号AK +ALIYUN_AK_SECRET=你的RAM子账号SK +IPWATCH_SECURITY_GROUP_ID=sg-0jl65y8luej10xz8neli +IPWATCH_REGION=cn-wulanchabu +IPWATCH_PORT=22 +``` + +2. RAM 子账号只授予该安全组的查询/增/删规则权限(`ecs:DescribeSecurityGroupAttribute`、 + `ecs:AuthorizeSecurityGroup`、`ecs:RevokeSecurityGroup`),不要使用主账号密钥。 +3. 重新部署代码后重启服务:`sudo systemctl restart myblog` +4. 验证服务状态:`curl -s http://127.0.0.1:8080/api/ipwatch/status`,应返回 `configured: true`。 + +**家庭端安装(Windows,一次性):** + +```powershell +cd deploy\ipwatch +Copy-Item ipwatch.conf.example ipwatch.conf +# 用记事本编辑 ipwatch.conf:SECRET 填与服务器 IPWATCH_SECRET 相同的值 +powershell -ExecutionPolicy Bypass -File .\install.ps1 # 注册每 30 分钟的计划任务 +Get-Content .\ipwatch.log # 查看首次运行结果 +``` + +**验证方法:** + +- 手动模拟一次 IP 变化:调用 `POST /api/ipwatch/report` 上报一个测试 IP,再到阿里云控制台 + 或 `deploy/ipwatch` 检查安全组 22 端口规则,确认“先加后删”;随后再上报回真实 IP。 +- 说明:测试期间新规则替换旧规则,SSH 会短暂不可用,测试后必须恢复真实 IP。 + +**注意事项:** + +- 安全组中其它端口(80/443/25565 等)和 `0.0.0.0/0` 规则不会被脚本触碰, + 它只操作“tcp 22/22 且来源为单个 IP(/32)”的规则。 +- 上报接口按来源 IP 限流(默认 60 秒一次,`IPWATCH_REPORT_INTERVAL_SECONDS` 可调)。 +- 若上报密钥泄露:改服务器 `.env` 的 `IPWATCH_SECRET` → 重启服务 → 同步改家庭端 `ipwatch.conf`。 +- 阿里云 AccessKey 建议定期在 RAM 控制台轮换,轮换后同步更新服务器 `.env`。 + + +### JS→WASM 预编译(把上传项目的 JS 编译为 WASM) + +后端提供“把项目演示目录里的 .js 预编译为 .wasm”的功能(基于 Javy / QuickJS), +编译产物与报告存放在 `uploads/demos/{项目id}/wasm/`。 + +**重要说明(先看这里):** + +- 浏览器页面**仍然运行原始 JS**。Javy 产物是“QuickJS 解释器 + 字节码”,不能操作 + DOM,速度也不如浏览器自带的 V8 JIT,所以它**不能替代**演示页里的 JS。 +- 这个功能的真正价值是**服务端沙箱执行**:把不可信的 JS 关进 WASM 沙箱里运行, + 隔离文件/网络/系统访问(符合“禁止直接运行用户上传代码”的安全规范)。 + +**安装编译工具(一次性,服务器上执行):** + +```bash +cd /var/www/blog +bash deploy/install_javy.sh # 下载约 14MB,安装到 /usr/local/bin/javy +javy --version # 验证:应输出 javy 9.1.0 +``` + +体积与性能分析: + +- Javy 官方 Linux 工具包:约 14MB(下载)/ 约 40MB(解压后),磁盘足够即可。 +- 编译产物大小:每个 .wasm 约 300KB~1MB(内含 QuickJS 运行时快照 + 你的 JS 字节码)。 +- 编译速度:普通几十 KB 的 JS 文件 1 秒内完成;总耗时与文件数成正比。 +- 运行性能:QuickJS 是解释器,同类计算基准通常比 V8 JIT 慢数倍到十几倍; + 用于沙箱隔离/短任务没问题,不适合做重计算。 + +**下载压缩包也会被一起打包为 wasm:** + +- 每次预编译时,除了演示目录里的 .js,还会把项目的下载压缩包(uploads/project/{uuid}.zip) + 内的全部 JS 合并打包为单个 wasm,输出到同目录 `uploads/project/{uuid}.wasm`(可直接下载)。 +- 说明:zip 本身不是 JS,无法直接编译;这里是把 zip 内的 .js 按文件名排序拼接成一个 + 临时 bundle 再交给 Javy,产物是“压缩包的 wasm 版本”(演示页仍运行原始 JS)。 +- 总量上限默认 10MB(`WASM_BUNDLE_MAX_SIZE_MB` 可调),超过会跳过打包并在报告中注明。 + +**使用方式(任选其一):** + +```bash +# 方式一:服务器上手动触发(推荐先用这个验证) +bash deploy/precompile_wasm.sh 1 + +# 方式二:博主登录后调用接口 +curl -X POST http://127.0.0.1:8080/api/project/1/precompile -H "Authorization: Bearer " + +# 查看最近一次编译报告(公开接口) +curl -s http://127.0.0.1:8080/api/project/1/wasm-report +``` + +**可选配置(追加到服务器 .env,重启生效):** + +```ini +WASM_COMPILE_ENABLED=true # 总开关 +JAVY_PATH=/usr/local/bin/javy # 编译工具路径 +WASM_MAX_JS_SIZE_MB=3 # 单个 JS 超过 3MB 跳过 +WASM_TIMEOUT_SECONDS=60 # 单文件编译超时 +``` + +运行验证(可选):编译产物可用 wasmtime 执行(服务器已装 v47.0.3,路径 /usr/local/bin/wasmtime): + +`ash +wasmtime run uploads/demos/1/wasm/data.wasm # 纯数据脚本可正常退出 +` + +注意:Javy 下载源是 GitHub Releases,服务器需能访问 GitHub;若失败可在本机下载后 +`scp` 到服务器再解压安装(包名:javy-x86_64-linux-v9.1.0.gz)。 diff --git a/deploy/deploy.conf b/deploy/deploy.conf new file mode 100644 index 0000000..b1a36cb --- /dev/null +++ b/deploy/deploy.conf @@ -0,0 +1,5 @@ +# MyBlog 部署配置(供 update.ps1 读取,可自行修改) +# 服务器地址:SSH 登录格式 user@公网IP(必须已配置 SSH 免密登录) +SERVER=root@8.145.36.108 +# 域名:用于更新后验证 HTTPS 是否正常;没有域名可留空 +DOMAIN=guzhujushi.cn diff --git a/deploy/install_javy.sh b/deploy/install_javy.sh new file mode 100644 index 0000000..5723599 --- /dev/null +++ b/deploy/install_javy.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# ============================================================ +# 安装 Javy(JS→WASM 编译工具)到服务器 +# ------------------------------------------------------------ +# 用法:bash deploy/install_javy.sh +# 作用:从 GitHub 官方 Releases 下载 Javy(约 14MB 单文件), +# 校验 SHA256 后安装到 /usr/local/bin/javy +# 幂等:已安装且版本一致时直接跳过,可重复执行 +# 体积说明:下载包约 14MB,解压后约 40MB,仅占磁盘少量空间 +# ============================================================ +set -eu + +JAVY_VERSION="${JAVY_VERSION:-9.1.0}" +JAVY_BIN="/usr/local/bin/javy" +URL="https://github.com/bytecodealliance/javy/releases/download/v${JAVY_VERSION}/javy-x86_64-linux-v${JAVY_VERSION}.gz" +SHA_URL="${URL}.sha256" + +# 已安装且版本匹配 -> 跳过(避免重复下载) +if [ -x "$JAVY_BIN" ] && "$JAVY_BIN" --version 2>/dev/null | grep -q "$JAVY_VERSION"; then + echo "Javy ${JAVY_VERSION} 已安装,跳过下载:$($JAVY_BIN --version)" + exit 0 +fi + +echo "== 下载 Javy ${JAVY_VERSION}(约 14MB)==" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +curl -fL -sS -o "$TMP/javy.gz" "$URL" +curl -fL -sS -o "$TMP/javy.gz.sha256" "$SHA_URL" + +echo "== 校验 SHA256 ==" +EXPECT=$(awk '{print $1}' "$TMP/javy.gz.sha256") +ACTUAL=$(sha256sum "$TMP/javy.gz" | awk '{print $1}') +if [ "$EXPECT" != "$ACTUAL" ]; then + echo "校验失败:期望 $EXPECT,实际 $ACTUAL" >&2 + exit 1 +fi +echo "校验通过" + +gunzip -f "$TMP/javy.gz" +install -m 0755 "$TMP/javy" "$JAVY_BIN" +echo "安装完成:$("$JAVY_BIN" --version)" +echo "路径:$JAVY_BIN" diff --git a/deploy/ipwatch/README.md b/deploy/ipwatch/README.md new file mode 100644 index 0000000..daadda6 --- /dev/null +++ b/deploy/ipwatch/README.md @@ -0,0 +1,39 @@ +# MyBlog IP 白名单自动更新(家庭端) + +## 这是什么 + +家庭宽带的公网 IP 会不定期变化。阿里云安全组 22 端口如果只放行固定 IP, +换 IP 后你就会被自己挡在门外。 + +这套方案让家庭电脑定时检测自己的公网 IP,一旦变化就通知服务器, +服务器自动调用阿里云 API 把安全组 22 端口白名单更新为最新 IP。 + +流程: +1. report.ps1 请求 博客/myip 获取当前公网 IP +2. 与上次记录比较,有变化时 POST /api/ipwatch/report +3. 服务器调用阿里云 API 更新安全组(先加新规则、后删旧规则) + +## 家庭端配置步骤(Windows) + +1. 复制 ipwatch.conf.example 为 ipwatch.conf,填写: + - SERVER:博客地址(默认 https://guzhujushi.cn) + - SECRET:与服务器 .env 中 IPWATCH_SECRET 相同 +2. 运行安装脚本(注册每 30 分钟的计划任务并立即执行一次): + powershell -ExecutionPolicy Bypass -File .\install.ps1 +3. 查看日志确认成功: + Get-Content .\ipwatch.log + 正常会看到:白名单更新成功:你的公网IP(服务器:白名单更新成功) + 或:公网 IP 未变化:xxx(表示无需更新,正常)。 + +## 手动运行 / 卸载 + +powershell -ExecutionPolicy Bypass -File .\report.ps1 +powershell -ExecutionPolicy Bypass -File .\uninstall.ps1 + +## 安全说明 + +- 家庭端只保存 IPWATCH_SECRET(随机字符串);阿里云 AccessKey 只存在服务器 + .env 中,不要复制到家庭端或提交到 git。 +- 如果 IPWATCH_SECRET 泄露:登录服务器改 .env 里的值,再同步修改本目录 + ipwatch.conf,最后重启后端服务即可。 +- 服务器端每次上报限流 60 秒一次,且只增删 22 端口 /32 单 IP 规则。 diff --git a/deploy/ipwatch/install.ps1 b/deploy/ipwatch/install.ps1 new file mode 100644 index 0000000..67564a4 --- /dev/null +++ b/deploy/ipwatch/install.ps1 @@ -0,0 +1,33 @@ +# ============================================================ +# 安装 MyBlog IP 白名单定时任务(家庭 Windows 电脑) +# 作用:注册 Windows 计划任务,每 30 分钟自动运行 report.ps1 +# 前置:已复制 ipwatch.conf.example 为 ipwatch.conf 并填写 +# 用法:powershell -ExecutionPolicy Bypass -File .\install.ps1 +# 说明:计划任务在“当前登录用户”下运行;如需开机无登录也运行, +# 请右键任务 -> 属性勾选“不管用户是否登录都要运行” +# ============================================================ +$ErrorActionPreference = "Stop" +$ScriptDir = $PSScriptRoot +$Report = Join-Path $ScriptDir "report.ps1" +$ConfFile = Join-Path $ScriptDir "ipwatch.conf" + +if (-not (Test-Path -LiteralPath $ConfFile)) { + Write-Host "缺少 ipwatch.conf:请先复制 ipwatch.conf.example 并填写 SERVER / SECRET" -ForegroundColor Red + exit 1 +} + +$TaskName = "MyBlogIPWatch" +# /TR 里对带空格的脚本路径加引号,PowerShell 会整体作为一个参数传给 schtasks +$Action = "powershell.exe -NoProfile -ExecutionPolicy Bypass -File `"$Report`"" + +& schtasks /Create /F /TN $TaskName /SC MINUTE /MO 30 /TR $Action | Out-Null +if ($LASTEXITCODE -ne 0) { + Write-Host "创建计划任务失败,请确认以管理员身份运行或检查 schtasks 输出" -ForegroundColor Red + exit 1 +} + +# 创建后立即运行一次,马上验证配置是否正确 +& schtasks /Run /TN $TaskName | Out-Null +Write-Host "计划任务 $TaskName 已创建:每 30 分钟自动检查公网 IP。" +Write-Host "日志文件:$(Join-Path $ScriptDir 'ipwatch.log')" +Write-Host "(首次运行结果可在日志中查看;卸载请运行 uninstall.ps1)" diff --git a/deploy/ipwatch/ipwatch.conf.example b/deploy/ipwatch/ipwatch.conf.example new file mode 100644 index 0000000..1566c38 --- /dev/null +++ b/deploy/ipwatch/ipwatch.conf.example @@ -0,0 +1,7 @@ +# MyBlog IP 白名单自动上报配置(家庭 Windows 电脑) +# 用法:复制本文件为 ipwatch.conf 并填写下面两项,再运行 install.ps1 +# +# SERVER:博客地址(必须能访问 /myip 与 /api/ipwatch/report),一般填域名 +# SECRET:必须与服务器 .env 里的 IPWATCH_SECRET 完全一致 +SERVER=https://guzhujushi.cn +SECRET=请填写与服务器 IPWATCH_SECRET 相同的值 diff --git a/deploy/ipwatch/report.ps1 b/deploy/ipwatch/report.ps1 new file mode 100644 index 0000000..a23b042 --- /dev/null +++ b/deploy/ipwatch/report.ps1 @@ -0,0 +1,77 @@ +# ============================================================ +# MyBlog IP 白名单自动上报脚本(在家庭 Windows 电脑上运行) +# 作用:读取当前公网 IP -> 与上次记录比较 -> 有变化就通知服务器 +# 更新阿里云安全组 22 端口白名单(避免换 IP 后 SSH 连不上) +# 用法: +# 手动运行: powershell -ExecutionPolicy Bypass -File .\report.ps1 +# 定时运行: 先运行 install.ps1(注册每 30 分钟执行一次的计划任务) +# 退出码:0=成功(含无需更新) 1=配置错误 2=取IP失败 3=IP不合法 4=服务器拒绝 5=网络错误 +# ============================================================ +$ErrorActionPreference = "Stop" +$ScriptDir = $PSScriptRoot +$ConfFile = Join-Path $ScriptDir "ipwatch.conf" +$LastFile = Join-Path $ScriptDir "lastip.txt" +$LogFile = Join-Path $ScriptDir "ipwatch.log" + +# ---------- 1. 读取配置 ---------- +$Server = "" +$Secret = "" +if (Test-Path -LiteralPath $ConfFile) { + Get-Content -LiteralPath $ConfFile -Encoding UTF8 | ForEach-Object { + $line = $_.Trim() + if ($line -match '^SERVER\s*=\s*(.+)$') { $Server = $Matches[1].Trim().Trim('"') } + elseif ($line -match '^SECRET\s*=\s*(.+)$') { $Secret = $Matches[1].Trim().Trim('"') } + } +} +if (-not $Server -or -not $Secret) { + Write-Host "配置不完整:请复制 ipwatch.conf.example 为 ipwatch.conf,并填写 SERVER 与 SECRET" -ForegroundColor Red + exit 1 +} + +# ---------- 2. 日志工具 ---------- +function Write-Log([string]$message) { + $line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $message" + try { Add-Content -LiteralPath $LogFile -Value $line -Encoding UTF8 } catch { } + Write-Host $line +} + +# ---------- 3. 获取当前公网 IP(走博客的 /myip 接口) ---------- +# 旧系统默认不启用 TLS1.2,先强制开启,否则 HTTPS 请求会失败 +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +try { + $current = (Invoke-RestMethod -Uri "$Server/myip" -TimeoutSec 20).ToString().Trim() +} catch { + Write-Log "获取公网 IP 失败:$($_.Exception.Message)" + exit 2 +} +if ($current -notmatch '^\d{1,3}(\.\d{1,3}){3}$') { + Write-Log "服务器返回的不是合法 IP:$current" + exit 3 +} + +# ---------- 4. 与上次记录比较,没变化就不打扰服务器 ---------- +$last = "" +if (Test-Path -LiteralPath $LastFile) { + $last = (Get-Content -LiteralPath $LastFile -Raw -Encoding UTF8).Trim() +} +if ($current -eq $last) { + Write-Host "公网 IP 未变化:$current" + exit 0 +} + +# ---------- 5. IP 有变化:上报给服务器,由服务器更新安全组 ---------- +try { + $body = @{ secret = $Secret; ip = $current } | ConvertTo-Json + $resp = Invoke-RestMethod -Uri "$Server/api/ipwatch/report" -Method Post -Body $body -ContentType "application/json; charset=utf-8" -TimeoutSec 40 + if ($resp.success) { + Set-Content -LiteralPath $LastFile -Value $current -Encoding UTF8 + Write-Log "白名单更新成功:$current(服务器:$($resp.message))" + exit 0 + } else { + Write-Log "服务器返回失败:$($resp.message)" + exit 4 + } +} catch { + Write-Log "上报失败:$($_.Exception.Message)" + exit 5 +} diff --git a/deploy/ipwatch/uninstall.ps1 b/deploy/ipwatch/uninstall.ps1 new file mode 100644 index 0000000..fd10539 --- /dev/null +++ b/deploy/ipwatch/uninstall.ps1 @@ -0,0 +1,4 @@ +# 卸载 MyBlog IP 白名单定时任务 +$ErrorActionPreference = "Stop" +& schtasks /Delete /F /TN "MyBlogIPWatch" | Out-Null +if ($LASTEXITCODE -eq 0) { Write-Host "计划任务 MyBlogIPWatch 已删除" } diff --git a/deploy/myblog.service b/deploy/myblog.service new file mode 100644 index 0000000..db33350 --- /dev/null +++ b/deploy/myblog.service @@ -0,0 +1,49 @@ +# ============================================================ +# MyBlog 后端 systemd 服务 +# ------------------------------------------------------------ +# 部署位置:/etc/systemd/system/myblog.service +# 生效命令: +# sudo systemctl daemon-reload +# sudo systemctl enable --now myblog # 开机自启 + 立即启动 +# 常用命令: +# sudo systemctl status myblog # 查看状态 +# sudo systemctl restart myblog # 重启 +# journalctl -u myblog -f # 跟踪日志 +# ============================================================ + +[Unit] +Description=MyBlog FastAPI Backend +# 等待网络就绪后再启动(uvicorn 需要绑定端口) +After=network.target + +[Service] +# 专用运行账号:系统用户、禁止登录,只拥有项目文件权限 +User=blog +Group=blog + +# 工作目录必须是项目根目录(backend 包、.env、uploads 都相对它定位) +WorkingDirectory=/var/www/blog + +# 启动命令:使用项目虚拟环境里的 uvicorn,只监听本机 8080(禁止公网直连) +ExecStart=/var/www/blog/venv/bin/uvicorn backend.main:app --host 127.0.0.1 --port 8080 + +# 崩溃后 3 秒自动重启;开机自启由 enable 管理 +Restart=always +RestartSec=3 + +# 环境变量说明: +# .env 不需要写进 EnvironmentFile —— 应用启动时会在代码里 +# 自动读取项目根目录 .env(backend/database.py 的 load_dotenv), +# 这样也能避免 systemd 解析中文值/注释行时出错。 +# 部署时请务必在 /var/www/blog/.env 中配置 JWT_SECRET,否则后端拒绝启动。 + +# ---------- 安全加固(可选) ---------- +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +# 允许写入:上传目录(用户上传文件)与 backend 目录(SQLite 数据库 backend/blog.db) +ReadWritePaths=/var/www/blog/uploads /var/www/blog/backend + +[Install] +WantedBy=multi-user.target diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..4c694f4 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,106 @@ +# ============================================================ +# MyBlog Nginx 站点配置 +# ------------------------------------------------------------ +# 部署位置:/etc/nginx/sites-available/myblog +# 启用命令:sudo ln -s /etc/nginx/sites-available/myblog /etc/nginx/sites-enabled/ +# 校验命令:sudo nginx -t +# 重载命令:sudo systemctl reload nginx +# +# 目录约定(与全局架构一致): +# /var/www/blog/frontend 前端静态文件(index.html / *.js / style.css) +# /var/www/blog/uploads 上传文件(avatar / article / project) +# /var/www/blog/backend 后端代码 +# /var/www/blog/.env 环境变量(应用启动时自动加载) +# ============================================================ + +# ============ HTTP 80:普通访问 + 证书校验 ============ +server { + listen 80; + listen [::]:80; + # 改成你的域名(或服务器公网 IP);certbot 会自动填入证书相关配置 + server_name your-domain.com; + # 允许上传大小上限:头像最大 4MB、文章视频最大 100MB,留出余量(默认 1MB 会拦截上传) + client_max_body_size 110m; + + # ---------- 安全响应头(防点击劫持 / MIME 嗅探 / 泄露来源页) ---------- + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + # 说明:前端 HTML 内已有 CSP meta;如需更强防护(frame-ancestors 等), + # 可在此追加:add_header Content-Security-Policy "default-src 'self'; ..." always; + + # Let's Encrypt 证书签发校验目录(certbot --webroot 模式使用) + # 需要先创建:sudo mkdir -p /var/www/blog/.well-known/acme-challenge + location /.well-known/acme-challenge/ { + root /var/www/blog; + } + + # ---------- /myip 返回客户端公网 IP(SSH 白名单自动更新用) ---------- + # 家庭端脚本先请求此地址获取当前公网 IP,变化后再调用 /api/ipwatch/report + # 注意:certbot 生成 443 的 server 块时,需要把这一段也复制进去 + location = /myip { + default_type text/plain; + return 200 $remote_addr; + } + + # ---------- /api/* 反向代理到 FastAPI ---------- + # FastAPI 只监听 127.0.0.1:8080,公网无法直连,必须走 Nginx 代理 + location /api/ { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # WASM 预编译等长任务可能超过 60s,放宽到 180s + proxy_read_timeout 180s; + } + + # 说明:服务器 /etc/nginx/mime.types 需追加一行 application/wasm wasm;(已执行) + # ---------- /uploads/* 静态文件 ---------- + # 头像、文章图片、项目压缩包由 Nginx 直接提供,不经过 Python + location /uploads/ { + alias /var/www/blog/uploads/; + expires 30d; + add_header Cache-Control "public"; + add_header X-Content-Type-Options "nosniff" always; + } + + # ---------- /demo/* 在线演示(L0 静态托管) ---------- + # 上传的项目 zip 若为纯 HTML/CSS/JS,后端解压到 uploads/demos/{项目id}/, + # 此处由 Nginx 直接提供(不经过 Python,禁止执行) + location /demo/ { + alias /var/www/blog/uploads/demos/; + index index.html; + try_files $uri $uri/ =404; + add_header X-Content-Type-Options "nosniff" always; + } + + # ---------- 前端静态文件(SPA) ---------- + # root 必须指向 frontend 目录:页面里引用的是 /main.js /style.css 等绝对路径 + location / { + root /var/www/blog/frontend; + index index.html; + # SPA 刷新支持: + # 先找真实文件($uri),再找目录($uri/), + # 都找不到就回退到 index.html,由前端 History API 路由接管 + try_files $uri $uri/ /index.html; + # 每次重新校验,避免部署后浏览器继续用旧版 JS 导致页面渲染异常 + add_header Cache-Control "no-cache"; + } +} + +# ============ HTTPS 443:用 certbot 一键开启 ============ +# 推荐方式(自动改写本配置文件并管理续期): +# sudo apt install certbot python3-certbot-nginx +# sudo certbot --nginx -d your-domain.com +# 执行成功后,本文件会被自动加入 443 监听与证书路径,无需手写。 +# 若要手写,参考下面的骨架(把上面 /api/、/uploads/、/ 三段复制进来即可): +# server { +# listen 443 ssl; +# listen [::]:443 ssl; +# server_name your-domain.com; +# ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; +# ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; +# ssl_protocols TLSv1.2 TLSv1.3; +# # ... 同上三个 location 块 ... +# } diff --git a/deploy/precompile_wasm.sh b/deploy/precompile_wasm.sh new file mode 100644 index 0000000..32908c4 --- /dev/null +++ b/deploy/precompile_wasm.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# ============================================================ +# 手动触发项目 JS→WASM 预编译(在服务器上执行) +# ------------------------------------------------------------ +# 用法:bash deploy/precompile_wasm.sh <项目ID> +# 示例:bash deploy/precompile_wasm.sh 1 # 预编译项目一 +# 效果:uploads/demos/{id}/wasm/ 下生成 .wasm 与 report.json +# 等价接口:POST /api/project/{id}/precompile(仅博主) +# ============================================================ +set -eu +PROJECT_ID="${1:?用法:bash deploy/precompile_wasm.sh <项目ID>}" +cd /var/www/blog +/var/www/blog/venv/bin/python -m backend.services.wasm_builder "$PROJECT_ID" +# 以 root 执行时,把产物属主修正为 blog,保证 Nginx 可读 +if [ "$(id -u)" = "0" ]; then + chown -R blog:blog "/var/www/blog/uploads/demos/${PROJECT_ID}/wasm" +fi diff --git a/deploy/update.ps1 b/deploy/update.ps1 new file mode 100644 index 0000000..6adb2d4 --- /dev/null +++ b/deploy/update.ps1 @@ -0,0 +1,94 @@ +# ============================================================ +# MyBlog 一键更新脚本(在 Windows 上运行) +# 作用:打包本地代码 -> 上传到服务器 -> 服务器自动 备份/迁移/重启/验证 +# 前置条件: +# 1. 已配置 SSH 免密登录(首次需执行:ssh-keygen 后 ssh-copy-id 服务器) +# 2. 服务器上已按 deploy/DEPLOY.md 完成首次部署(myblog 服务存在) +# 用法(在项目根目录 D:\MyBlog 打开 PowerShell): +# powershell -ExecutionPolicy Bypass -File .\deploy\update.ps1 +# 或直接指定服务器:.\deploy\update.ps1 -Server root@8.145.36.108 +# 常用参数: +# -Server 服务器地址,默认读取 deploy/deploy.conf(缺省用 root@8.145.36.108) +# -IncludeUploads 同时同步本地 uploads/(首次部署或新增静态资源时用) +# 安全说明: +# - 永远不打包 .env 与 backend/blog.db(密钥与数据库只在服务器上) +# - 默认排除 uploads/:网页上传的文件只在服务器,本地打包不覆盖 +# ============================================================ + +param( + [string]$Server = "", + [switch]$IncludeUploads +) + +$ErrorActionPreference = "Stop" +$ScriptDir = $PSScriptRoot # deploy 目录 +$ProjectRoot = Split-Path -Parent $ScriptDir # 项目根目录 +$PackageName = "myblog.tar.gz" +$RemoteScript = "update_server.sh" + +function Write-Step([string]$msg) { Write-Host ""; Write-Host "== $msg ==" -ForegroundColor Cyan } + +# ---------- 1. 确定服务器地址(命令行参数 > deploy.conf > 默认值) ---------- +if (-not $Server -and (Test-Path (Join-Path $ScriptDir "deploy.conf"))) { + $confLine = Get-Content (Join-Path $ScriptDir "deploy.conf") | Where-Object { $_ -match '^\s*SERVER\s*=' } | Select-Object -First 1 + if ($confLine) { $Server = ($confLine -split '=', 2)[1].Trim() } + $domainLine = Get-Content (Join-Path $ScriptDir "deploy.conf") | Where-Object { $_ -match '^\s*DOMAIN\s*=' } | Select-Object -First 1 + if ($domainLine) { $Domain = ($domainLine -split '=', 2)[1].Trim() } +} +if (-not $Server) { $Server = "root@8.145.36.108" } +Write-Host "目标服务器:$Server" +if ($Domain) { Write-Host "验证域名:$Domain" } + +# ---------- 2. 检查依赖工具 ---------- +foreach ($tool in @("tar", "scp", "ssh")) { + if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { + throw "缺少工具 $tool,请先安装 OpenSSH 客户端(Windows 设置 -> 可选功能)。" + } +} + +# ---------- 3. 打包本地代码(排除密钥/数据库/缓存/上传目录) ---------- +Write-Step "打包本地代码" +$tmpPkg = Join-Path $env:TEMP $PackageName +if (Test-Path $tmpPkg) { Remove-Item -LiteralPath $tmpPkg -Force } +Push-Location $ProjectRoot +try { + # 排除本地敏感运行数据:数据库、密钥配置、IPWatch 家庭端密钥与日志 + $excludes = @("--exclude=backend/blog.db", "--exclude=backend/__pycache__", + "--exclude=backend/routers/__pycache__", + "--exclude=backend/services/__pycache__", "--exclude=venv", + "--exclude=deploy/ipwatch/ipwatch.conf", + "--exclude=deploy/ipwatch/lastip.txt", + "--exclude=deploy/ipwatch/ipwatch.log") + if (-not $IncludeUploads) { $excludes += "--exclude=uploads" } + $targets = @("frontend", "backend", "deploy", "requirements.txt", ".env.example", "README.md") + & tar -czf $tmpPkg @excludes @targets + if ($LASTEXITCODE -ne 0) { throw "tar 打包失败" } +} finally { + Pop-Location +} +# 校验包内不含敏感文件(防止配置写错把密钥带上服务器) +$bad = tar -tzf $tmpPkg | Select-String "(^|/)\.env$|blog\.db|__pycache__|^uploads/|ipwatch\.(conf|log)$|lastip\.txt" | Select-Object -First 5 +if ($bad) { throw "更新包内含不应上传的文件:$($bad -join ', ')" } +Write-Host ("更新包已生成:{0}({1:N0} KB)" -f $tmpPkg, ((Get-Item $tmpPkg).Length / 1KB)) + +# ---------- 4. 上传到服务器 ---------- +Write-Step "上传更新包与服务器脚本" +& scp -o BatchMode=yes -o StrictHostKeyChecking=accept-new $tmpPkg "$($Server):/tmp/$PackageName" +if ($LASTEXITCODE -ne 0) { throw "上传失败:请确认已配置 SSH 免密登录" } +& scp -o BatchMode=yes (Join-Path $ScriptDir $RemoteScript) "$($Server):/tmp/$RemoteScript" +if ($LASTEXITCODE -ne 0) { throw "上传服务器脚本失败" } +Write-Host "上传完成" + +# ---------- 5. 执行服务器端更新(备份/解压/迁移/重启/验证) ---------- +Write-Step "执行服务器端更新(备份、迁移、重启、验证)" +$domainArg = if ($Domain) { " '$Domain'" } else { "" } +# 服务器端脚本若带 Windows 换行(CRLF)会导致 bash 解析失败,先统一转成 LF 再执行 +& ssh -o BatchMode=yes $Server "sed -i 's/\r`$//' /tmp/$RemoteScript && bash /tmp/$RemoteScript$domainArg" +if ($LASTEXITCODE -ne 0) { + Write-Host "服务器端更新失败,请检查上方输出。可用备份回滚:/root/blog-backups/" -ForegroundColor Red + exit 1 +} + +Write-Host "" +Write-Host "✅ 更新完成!请打开 https://$Domain 验证。" -ForegroundColor Green + diff --git a/deploy/update_server.sh b/deploy/update_server.sh new file mode 100644 index 0000000..0e82f91 --- /dev/null +++ b/deploy/update_server.sh @@ -0,0 +1,127 @@ +#!/bin/bash +# ============================================================ +# MyBlog 服务器端更新脚本(由本地 deploy/update.ps1 上传并执行) +# 职责:备份 -> 解压新代码 -> 数据库迁移 -> 重启服务 -> 验证 +# 用法:bash /tmp/update_server.sh [域名] +# 适用:Ubuntu + systemd(myblog 服务);需要 root 或已配置免密 sudo +# 安全说明: +# - 更新前自动备份到 /root/blog-backups(含数据库与上传文件),可随时回滚 +# - 只增量覆盖代码文件,不删除服务器上的任何文件(不会加 --delete) +# - 数据库迁移是幂等的:重复执行只会补齐缺失的列,不会损坏数据 +# ============================================================ +set -eu +# bash 支持 pipefail(dash/sh 下自动跳过),保证失败即退出 +if [ -n "${BASH_VERSION:-}" ]; then set -o pipefail; fi + +BLOG_DIR="/var/www/blog" +PKG="/tmp/myblog.tar.gz" +BACKUP_DIR="/root/blog-backups" +DOMAIN="${1:-}" + +# 非 root 用户运行时自动加 sudo(前提:已配置免密 sudo) +SUDO="" +if [ "$(id -u)" -ne 0 ]; then + SUDO="sudo" +fi + +echo "== [1/5] 更新前备份 ==" +$SUDO mkdir -p "$BACKUP_DIR" +TS=$(date +%Y%m%d_%H%M%S) +$SUDO tar -czf "$BACKUP_DIR/blog_backup_$TS.tar.gz" --exclude=venv --exclude='*/__pycache__' -C "$BLOG_DIR" . +echo "备份完成:$BACKUP_DIR/blog_backup_$TS.tar.gz" + +echo "== [2/5] 解压新代码 ==" +if [ ! -f "$PKG" ]; then + echo "错误:找不到更新包 $PKG,请先通过本地脚本上传" >&2 + exit 1 +fi +$SUDO tar -xzf "$PKG" -C "$BLOG_DIR" +$SUDO chown -R blog:blog "$BLOG_DIR" +# 前端缓存指纹:每次部署自动生成新版本号,浏览器强制拉取最新 JS/CSS,无需手动 Ctrl+F5 +VERSION=$(date +%Y%m%d%H%M%S) +$SUDO sed -i "s/__VERSION__/${VERSION}/g" "$BLOG_DIR/frontend/index.html" +echo "前端版本号:${VERSION}" + +# 把 Windows 上传的脚本统一转成 LF 行尾,防止 bash 解析 CR 报错 +find "$BLOG_DIR/deploy" -maxdepth 2 -name '*.sh' -type f -exec sed -i 's/\r$//' {} + +echo "解压完成,文件属主已修正为 blog:blog" + +echo "== [3/5] 数据库迁移(幂等) ==" +python3 - "$BLOG_DIR" <<'PY' +import sqlite3, sys +from pathlib import Path +base = Path(sys.argv[1]) +db = base / "backend" / "blog.db" +# 读取 .env 的 DATABASE_URL(与后端 database.py 的解析方式一致,留空用默认) +try: + for line in (base / ".env").read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line.startswith("DATABASE_URL="): + val = line.split("=", 1)[1].strip().strip('"').strip("'") + if val.startswith("sqlite:///"): + p = val[len("sqlite:///"):] + db = Path(p) if p.startswith("/") else base / p + break +except OSError: + pass +print("目标数据库:", db) +con = sqlite3.connect(db) +cur = con.cursor() +def ensure_table(table, ddl): + rows = cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchall() + if not rows: + cur.execute(ddl) + print(f"建表:{table}") + else: + print(f"已存在:{table}") + +def ensure_column(table, col, ddl): + cols = [r[1] for r in cur.execute(f"PRAGMA table_info({table})").fetchall()] + if col not in cols: + cur.execute(f"ALTER TABLE {table} ADD COLUMN {ddl}") + print(f"迁移:{table}.{col} 已新增") + else: + print(f"已存在:{table}.{col}") +ensure_column("articles", "category", "category VARCHAR(30) NOT NULL DEFAULT 'life'") +ensure_column("users", "token_version", "token_version INTEGER NOT NULL DEFAULT 0") +ensure_column("users", "avatar_updated_time", "avatar_updated_time DATETIME") +ensure_column("friends", "created_time", "created_time DATETIME") +ensure_table("projects", """ +CREATE TABLE IF NOT EXISTS projects ( + id INTEGER NOT NULL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + tech VARCHAR(500), + project_type VARCHAR(20) NOT NULL DEFAULT 'static', + demo_url VARCHAR(500), + download_url VARCHAR(500), + github_url VARCHAR(500), + created_time DATETIME NOT NULL, + updated_time DATETIME NOT NULL +)""") +ensure_column("projects", "visibility", "visibility VARCHAR(20) NOT NULL DEFAULT 'public'") +con.commit() +con.close() +print("数据库迁移完成") +PY + +echo "== [4/5] 重启后端服务 ==" +$SUDO systemctl restart myblog +sleep 3 +if ! $SUDO systemctl is-active --quiet myblog; then + echo "错误:myblog 服务未正常运行,请查看日志:journalctl -u myblog -n 50" >&2 + exit 1 +fi +echo "myblog 服务运行正常" + +echo "== [5/5] 验证 ==" +echo "--- 后端接口(应返回 success:true) ---" +curl -s http://127.0.0.1:8080/api/article/list | head -c 200 +echo +echo "--- 新前端是否生效(发布管理弹窗标记 pub-confirm 数量) ---" +grep -c "pub-confirm" "$BLOG_DIR/frontend/manage.js" || true +if [ -n "$DOMAIN" ]; then + echo "--- 线上 HTTPS($DOMAIN) ---" + curl -sk -o /dev/null -w "%{http_code}\n" "https://$DOMAIN/" || echo "HTTPS 检查失败(可能是证书或网络问题)" +fi +echo "更新完成。如需回滚:备份在 $BACKUP_DIR/" diff --git a/frontend/api.js b/frontend/api.js new file mode 100644 index 0000000..41d6f51 --- /dev/null +++ b/frontend/api.js @@ -0,0 +1,63 @@ +/* ===================================================================== + * 网络层(api.js) + * 统一调用 /api/* 接口;localStorage 只保存 token 与 username(绝不保存密码)。 + * ===================================================================== */ + +export class ApiError extends Error { + constructor(message, status) { + super(message); + this.status = status; + } +} + +export async function api(path, { method = "GET", body } = {}) { + const headers = { "Content-Type": "application/json" }; + const token = localStorage.getItem("token"); + if (token) headers["Authorization"] = `Bearer ${token}`; + let res; + try { + res = await fetch(path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); + } catch (err) { + throw new ApiError("网络请求失败,请稍后再试", 0); + } + let json = null; + try { json = await res.json(); } catch (err) { /* 忽略非 JSON 响应 */ } + if (!json || typeof json.success === "undefined") { + throw new ApiError(`服务器响应异常(${res.status})`, res.status); + } + if (!json.success) throw new ApiError(json.message || "请求失败", res.status); + return json.data; +} + +// multipart 表单提交(普通字段 + 单个文件),用于项目管理等 +export async function uploadForm(path, fields = {}, file = null, method = "POST") { + const form = new FormData(); + for (const [key, value] of Object.entries(fields)) { + if (value !== undefined && value !== null && value !== "") form.append(key, String(value)); + } + if (file) form.append("file", file); + const headers = {}; + const token = localStorage.getItem("token"); + if (token) headers["Authorization"] = `Bearer ${token}`; + const res = await fetch(path, { method, headers, body: form }); + let json = null; + try { json = await res.json(); } catch (err) { /* 忽略非 JSON 响应 */ } + if (!json || !json.success) throw new ApiError((json && json.message) || "操作失败", res.status); + return json.data; +} + +// multipart 上传(头像/图片/项目文件),返回 {url, filename, size} +export async function uploadFile(path, file, extra = {}) { + const form = new FormData(); + form.append("file", file); + // extra:附加字段(如上传文档时携带文章分区 category,供后端校验) + for (const [key, value] of Object.entries(extra)) form.append(key, value); + const headers = {}; + const token = localStorage.getItem("token"); + if (token) headers["Authorization"] = `Bearer ${token}`; + const res = await fetch(path, { method: "POST", headers, body: form }); + let json = null; + try { json = await res.json(); } catch (err) { /* 忽略非 JSON 响应 */ } + if (!json || !json.success) throw new ApiError((json && json.message) || "上传失败", res.status); + return json.data; +} \ No newline at end of file diff --git a/frontend/article.js b/frontend/article.js new file mode 100644 index 0000000..0e05f7c --- /dev/null +++ b/frontend/article.js @@ -0,0 +1,379 @@ +/* ===================================================================== + * 文章视图模块(article.js) + * 首页文章列表(分页)/ 分区 / 项目 / 简介 / 文章详情 / 点赞。 + * ===================================================================== */ + +import { ABOUT_DEFAULT, ARTICLE_CATEGORIES, DONATE_IMAGE, PARTITIONS, SOCIAL_LINKS, state } from "./state.js"; +import { $, escapeHtml, formatDate, openModal, showToast } from "./utils.js"; +import { api } from "./api.js"; +import { renderMarkdown } from "./markdown.js"; +import { openLoginModal } from "./auth.js"; +import { applyFriend } from "./friend.js"; +import { renderComments } from "./comment.js"; + +/* ---------------- 首页(简介 + 社交链接 + 全部文章,分页加载) ---------------- */ +export async function renderHome(app) { + document.title = "首页 - 孤竹居士的博客"; + app.innerHTML = ` +

加载中…

+

最新文章

+
`; + await renderHomeAbout($("#about-card", app)); + await renderArticleList($("#article-feed", app), 6); +} + +/** 主页简介卡片:头像、名字、简介(线上可改)、社交账号链接 */ +async function renderHomeAbout(el) { + let profile = null; + try { + profile = await api("/api/user/blogger"); + } catch (err) { /* 简介加载失败不阻塞页面 */ } + const name = (profile && profile.username) || ABOUT_DEFAULT.name; + const avatar = (profile && profile.avatar) || ""; + const bio = (profile && profile.bio) + ? profile.bio.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) + : ABOUT_DEFAULT.bio; + const avatarHtml = avatar + ? `头像` + : `
${escapeHtml(name.slice(0, 1))}
`; + el.innerHTML = ` + ${avatarHtml} +
+

${escapeHtml(name)}

+

${escapeHtml(ABOUT_DEFAULT.tagline)}

+
${bio.map((p) => `

${escapeHtml(p)}

`).join("")}
+ +
+ ${state.user && state.user.role === "blogger" ? '' : ""} + +
+
${(ABOUT_DEFAULT.tags || []).map((t) => `${escapeHtml(t)}`).join("")}
+
`; + + // 绑定按钮事件:打赏弹窗(所有人可见);编辑简介(仅博主,展示在主页简介处) + $("#btn-donate", el).addEventListener("click", openDonateModal); + const bioBtn = $("#btn-edit-bio", el); + if (bioBtn) bioBtn.addEventListener("click", () => openBioEditor(el, profile && profile.bio)); +} + + +/** 打赏弹窗:展示收款二维码;点击图片外任意位置关闭(按 Esc 也可关闭) */ +function openDonateModal() { + const overlay = document.createElement("div"); + overlay.className = "modal-overlay"; + overlay.innerHTML = ` + `; + const close = () => { + overlay.remove(); + document.removeEventListener("keydown", onKey); + }; + const onKey = (e) => { if (e.key === "Escape") close(); }; + document.addEventListener("keydown", onKey); + // 点击图片本身不关闭(方便长按识别),点击其他任意位置关闭 + overlay.addEventListener("click", (e) => { + if (e.target.closest("img")) return; + close(); + }); + document.body.appendChild(overlay); +} + +/** 简介编辑弹窗(仅博主):入口在主页简介处,保存后立即刷新简介卡片 */ +async function openBioEditor(aboutEl, currentBio) { + const form = document.createElement("div"); + form.className = "form"; + form.innerHTML = ` + + + `; + const modal = openModal({ title: "编辑简介", content: form }); + $("#btn-bio-save", form).addEventListener("click", async () => { + const btn = $("#btn-bio-save", form); + const bio = $("#bio-edit-input", form).value.trim(); + btn.disabled = true; btn.textContent = "保存中…"; + try { + await api("/api/user/profile", { method: "PUT", body: { bio } }); + modal.close(); + showToast("简介已保存", "success"); + await renderHomeAbout(aboutEl); // 重新拉取最新简介并重绘 + } catch (err) { + showToast(err.message, "error"); + btn.disabled = false; btn.textContent = "保存简介"; + } + }); +} + +export function articleCard(article, compact = false) { + const card = document.createElement("a"); + card.className = "article-card"; + // 文章详情地址按分区决定前缀:/life/1 或 /study/1 + const category = article.category || "life"; + card.href = `/${category}/${article.id}`; + card.setAttribute("data-link", ""); + const isFriendOnly = article.visibility === "friend"; + // 游客视角的好友文章:后端仅返回 id/title/cover/visibility(无 author_username 字段) + const locked = isFriendOnly && typeof article.author_username === "undefined"; + const coverHtml = article.cover + ? `
${escapeHtml(article.title)}${isFriendOnly ? '好友专属' : ""}
` + : `
${isFriendOnly ? "🔒" : ""}
`; + // 分区页(compact)只展示封面 + 标题;首页展示完整元信息 + const metaHtml = compact ? "" : ` +
+ ${isFriendOnly ? '好友专属' : '公开'} + ${locked + ? '仅好友可见全文' + : `${escapeHtml(article.author_username || "")}${formatDate(article.created_time)}`} +
`; + card.innerHTML = ` + ${coverHtml} +
+

${escapeHtml(article.title)}

+ ${metaHtml} +
`; + return card; +} + +/* ---------------- 文章列表分页加载(首页 / 分区复用) ---------------- */ +export async function renderArticleList(container, pageSize = 6, category = "") { + container.innerHTML = ""; + const list = document.createElement("div"); + list.className = "article-list"; + container.appendChild(list); + const moreBtn = document.createElement("button"); + moreBtn.className = "btn btn-outline btn-block load-more-btn"; + moreBtn.textContent = "加载更多"; + moreBtn.hidden = true; + moreBtn.addEventListener("click", () => loadPage()); + container.appendChild(moreBtn); + + let page = 1; + let loaded = 0; + let total = 0; + + async function loadPage() { + moreBtn.disabled = true; + moreBtn.textContent = "加载中…"; + try { + // category 为空 = 首页全部文章;传入分区则按分区过滤 + const categoryQuery = category ? `&category=${encodeURIComponent(category)}` : ""; + const data = await api(`/api/article/list?page=${page}&page_size=${pageSize}${categoryQuery}`); + total = data.total; + data.items.forEach((a) => list.appendChild(articleCard(a, Boolean(category)))); + loaded += data.items.length; + page += 1; + if (loaded === 0 && total === 0) { + list.innerHTML = '

暂无文章

'; + moreBtn.hidden = true; + moreBtn.textContent = "加载更多"; + return; + } + moreBtn.hidden = loaded >= total; + moreBtn.textContent = "加载更多"; + } catch (err) { + moreBtn.textContent = "加载更多"; + showToast(err.message, "error"); + } finally { + moreBtn.disabled = false; + } + } + await loadPage(); +} + +/* ---------------- 分区 ---------------- */ +export async function renderPartition(app, slug) { + if (slug === "projects") return renderProjects(app); + const partition = PARTITIONS.find((p) => p.slug === slug); + if (!partition) return renderNotFound(app); + document.title = `${partition.name} - 孤竹居士的博客`; + const sub = slug === "life" ? "生活点滴记录" : "学习笔记与总结"; + app.innerHTML = ` +

${escapeHtml(partition.name)}

+

${sub}

+
`; + // 分区页只展示本分区的文章卡片(封面 + 标题) + await renderArticleList($("#article-feed", app), 6, slug); +} + +export async function renderProjects(app) { + document.title = "我的项目 - 孤竹居士的博客"; + app.innerHTML = ` +

我的项目

+

在线体验与源码下载

+
`; + const grid = $(".project-grid", app); + let items = []; + try { items = await api("/api/project/list"); } + catch (err) { showToast(err.message, "error"); } + if (!items.length) { grid.innerHTML = '

暂无项目

'; return; } + items.forEach((project) => { + const card = document.createElement("a"); + card.className = "project-card"; + card.href = `/projects/${project.id}`; + card.setAttribute("data-link", ""); + const badge = project.project_type === "static" + ? '在线演示' + : 'GitHub 链接'; + const visBadge = project.visibility === "friend" + ? '🔒 好友专属' + : '公开'; + const lockedIcon = project.visibility === "friend" ? " 🔒" : ""; + card.innerHTML = ` +

${escapeHtml(project.name)}${lockedIcon}

+

${escapeHtml(project.description || "")}

+
${(project.tech || "").split(",").map((t) => t.trim()).filter(Boolean).map((t) => `${escapeHtml(t)}`).join("")}
+ ${badge} + ${visBadge} + 查看详情`; + grid.appendChild(card); + }); +} + +/* ---------------- 项目详情 ---------------- */ +export async function renderProjectDetail(app, id) { + let project = null; + try { project = await api(`/api/project/${id}`); } + catch (err) { return renderNotFound(app); } + if (!project) return renderNotFound(app); + document.title = `${project.name} - 孤竹居士的博客`; + const demoBtn = project.demo_url + ? `🚀 在线运行` + : ""; + const downloadBtn = project.download_url + ? `⬇ 下载项目` + : ""; + app.innerHTML = ` + +
+

${escapeHtml(project.name)}

+

${escapeHtml(project.description || "")}

+
${(project.tech || "").split(",").map((t) => t.trim()).filter(Boolean).map((t) => `${escapeHtml(t)}`).join("")}
+
+ ${demoBtn} + ${downloadBtn} +
+
`; +} + +/* 简介已并入首页(renderHomeAbout),不再单独成页 */ + +export function renderNotFound(app) { + document.title = "页面不存在 - 孤竹居士的博客"; + app.innerHTML = ` +
+

404

+

页面不存在或已被移除

+ 返回首页 +
`; +} + +/* ---------------- 文章详情(含点赞) ---------------- */ +export async function renderArticle(app, articleId) { + const article = await api(`/api/article/${articleId}`); + const isFriendOnly = article.visibility === "friend"; + const locked = isFriendOnly && article.content == null; // 游客视角:正文为 null + const category = article.category || "life"; + document.title = `${article.title} - 孤竹居士的博客`; + + app.innerHTML = ` + +
+

${escapeHtml(article.title)}

+ + ${article.cover ? `
${escapeHtml(article.title)}
` : ""} +
${locked ? lockedBox() : renderMarkdown(article.content)}
+
+

评论

+
`; + + if (locked) bindLockedApply(); + await renderLikeBar($("#like-bar", app), articleId); + await renderComments($("#comments", app), articleId); +} + +function lockedBox() { + let actionHtml = ""; + if (!state.user) { + actionHtml = ''; + } else if (state.user.role === "visitor") { + if (state.friendStatus === "pending") actionHtml = '

申请已提交,等待博主审批

'; + else if (state.friendStatus === "rejected") actionHtml = ''; + else actionHtml = ''; + } else { + actionHtml = '

登录好友账号后即可查看全文

'; + } + return ` +
+

🔒 该文章仅好友可见

+ ${actionHtml} +
`; +} + +function bindLockedApply() { + const btn = $("#btn-locked-apply"); + if (btn) { + btn.addEventListener("click", () => { + if (!state.user) openLoginModal(); + else applyFriend(); + }); + } +} + +export async function renderLikeBar(el, articleId) { + // 后端只返回数量与“当前用户是否已点赞”,不返回点赞名单(防用户名枚举) + let likes = { count: 0, includes_me: false }; + try { + likes = await api(`/api/like/list?article_id=${articleId}`); + } catch (err) { /* 好友文章对游客不可见点赞,忽略 */ } + const liked = !!likes.includes_me; + el.innerHTML = ` + `; + const btn = $("#btn-like", el); + if (!btn) return; + btn.addEventListener("click", async () => { + if (!state.user) { + openLoginModal(); + showToast("请先登录后点赞", "info"); + return; + } + if (state.user.role === "visitor") { + if (state.friendStatus === "pending") { + showToast("好友申请审批通过后即可点赞", "info"); + return; + } + showToast("申请成为好友,审批通过后即可点赞", "info"); + applyFriend(); + return; + } + if (liked) { + showToast("您已经点过赞了", "info"); + return; + } + btn.disabled = true; + try { + await api("/api/like/add", { method: "POST", body: { article_id: articleId } }); + showToast("点赞成功", "success"); + await renderLikeBar(el, articleId); + } catch (err) { + showToast(err.message, "error"); + } + }); +} \ No newline at end of file diff --git a/frontend/auth.js b/frontend/auth.js new file mode 100644 index 0000000..9905639 --- /dev/null +++ b/frontend/auth.js @@ -0,0 +1,210 @@ +/* ===================================================================== + * 认证模块(auth.js) + * 登录 / 注册 / 会话保存 / 当前用户刷新。 + * localStorage 只保存 token 与 username(绝不保存密码)。 + * ===================================================================== */ + +import { state } from "./state.js"; +import { $, escapeHtml, openModal, showToast } from "./utils.js"; +import { api, uploadFile } from "./api.js"; +import { render, renderShell } from "./router.js"; + +export function isLoggedIn() { return !!localStorage.getItem("token"); } + +export function saveSession(token, username) { + localStorage.setItem("token", token); + localStorage.setItem("username", username); +} + +export function logout() { + localStorage.removeItem("token"); + localStorage.removeItem("username"); + state.user = null; + state.friendStatus = "none"; + renderShell(); + render(); + showToast("已退出登录"); +} + +export async function refreshUser() { + if (!isLoggedIn()) { state.user = null; state.friendStatus = "none"; return; } + try { + const data = await api("/api/user/level"); + state.user = { + id: data.user_id, + username: localStorage.getItem("username") || "", + role: data.role, + }; + if (data.role === "blogger") state.friendStatus = "blogger"; + else if (data.role === "friend") state.friendStatus = "accepted"; + else { + try { + const fs = await api("/api/friend/status"); + state.friendStatus = fs.status; + } catch (err) { state.friendStatus = "none"; } + } + } catch (err) { + if (err.status === 401) logout(); + else showToast(err.message, "error"); + } +} + +export async function afterAuthSuccess(username) { + await refreshUser(); // 拉取真实角色与好友状态 + renderShell(); + render(); +} + +export function openLoginModal() { + const form = document.createElement("form"); + form.className = "form"; + form.innerHTML = ` + + + + + +

还没有账号?立即注册

`; + const modal = openModal({ title: "登录", content: form }); + + $("#goto-register", form).addEventListener("click", (e) => { + e.preventDefault(); + modal.close(); + openRegisterModal(); + }); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const email = form.email.value.trim(); + const password = form.password.value; + if (!email || !password) { showToast("请填写邮箱和密码", "error"); return; } + const btn = $('button[type="submit"]', form); + btn.disabled = true; btn.textContent = "登录中…"; + try { + const data = await api("/api/login", { method: "POST", body: { email, password } }); + saveSession(data.token, data.username); + modal.close(); + await afterAuthSuccess(data.username); + showToast("登录成功", "success"); + } catch (err) { + showToast(err.message, "error"); + } finally { + btn.disabled = false; btn.textContent = "登录"; + } + }); +} + +/** 好友个人设置:上传自己的头像(访客禁止上传,后端有角色校验) */ +export function openProfileModal() { + const content = document.createElement("div"); + content.innerHTML = ` +
+
+
+ + +

支持 jpg / png / webp,最大 4MB

+
+
`; + const modal = openModal({ title: "个人设置", content }); + const avatarBox = $("#profile-avatar", content); + const renderPreview = (url) => { + avatarBox.innerHTML = url + ? `头像预览` + : ''; + }; + api("/api/user/me").then((me) => renderPreview(me.avatar || "")).catch(() => {}); + $("#btn-avatar-upload", content).addEventListener("click", () => $("#avatar-file", content).click()); + $("#avatar-file", content).addEventListener("change", async () => { + const file = $("#avatar-file", content).files && $("#avatar-file", content).files[0]; + if (!file) return; + try { + const data = await uploadFile("/api/upload/avatar", file); + renderPreview(data.url); + showToast("头像上传成功", "success"); + } catch (err) { showToast(err.message, "error"); } + $("#avatar-file", content).value = ""; + }); +} + +export function openRegisterModal() { + const form = document.createElement("form"); + form.className = "form"; + form.innerHTML = ` + + + +
+ + +
+ + + + + +

已有账号?直接登录

`; + const modal = openModal({ title: "注册", content: form }); + + $("#goto-login", form).addEventListener("click", (e) => { + e.preventDefault(); + modal.close(); + openLoginModal(); + }); + + // 发送邮箱验证码(60 秒倒计时防重复点击;后端另有按邮箱/IP 的小时限流) + const sendCodeBtn = $("#btn-send-code", form); + sendCodeBtn.addEventListener("click", async () => { + const email = form.email.value.trim(); + if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + showToast("请先填写正确的邮箱", "error"); + return; + } + sendCodeBtn.disabled = true; + sendCodeBtn.textContent = "发送中…"; + try { + await api("/api/email/send-code", { method: "POST", body: { email, purpose: "register" } }); + showToast("验证码已发送,请查收邮件", "success"); + let countdown = 60; + const timer = setInterval(() => { + countdown -= 1; + if (countdown <= 0) { + clearInterval(timer); + sendCodeBtn.disabled = false; + sendCodeBtn.textContent = "发送验证码"; + } else { + sendCodeBtn.textContent = `${countdown}s 后重发`; + } + }, 1000); + } catch (err) { + showToast(err.message, "error"); + sendCodeBtn.disabled = false; + sendCodeBtn.textContent = "发送验证码"; + } + }); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const email = form.email.value.trim(); + const username = form.username.value.trim(); + const password = form.password.value; + const code = form.code.value.trim(); + if (!email || !username || !password || !code) { showToast("请填写完整信息", "error"); return; } + if (password.length < 6) { showToast("密码至少 6 位", "error"); return; } + const btn = $('button[type="submit"]', form); + btn.disabled = true; btn.textContent = "注册中…"; + try { + await api("/api/register", { method: "POST", body: { email, username, password, code } }); + // 注册成功后自动登录 + const loginData = await api("/api/login", { method: "POST", body: { email, password } }); + saveSession(loginData.token, username); + modal.close(); + await afterAuthSuccess(username); + showToast("注册成功", "success"); + } catch (err) { + showToast(err.message, "error"); + } finally { + btn.disabled = false; btn.textContent = "注册"; + } + }); +} \ No newline at end of file diff --git a/frontend/comment.js b/frontend/comment.js new file mode 100644 index 0000000..05ea500 --- /dev/null +++ b/frontend/comment.js @@ -0,0 +1,126 @@ +/* ===================================================================== + * 评论模块(comment.js) + * 评论列表(按 parent_id 分组展示回复)+ 发表评论 / 回复表单。 + * 仅好友或博主可评论。 + * ===================================================================== */ + +import { state } from "./state.js"; +import { $, escapeHtml, formatDate, showToast } from "./utils.js"; +import { api } from "./api.js"; +import { openLoginModal } from "./auth.js"; +import { applyFriend } from "./friend.js"; + +export async function renderComments(el, articleId) { + let comments = []; + try { + comments = await api(`/api/comment/list?article_id=${articleId}`); + } catch (err) { /* 好友文章对游客不可见评论,忽略 */ } + el.innerHTML = `

共 ${comments.length} 条评论

`; + + const list = document.createElement("div"); + list.className = "comment-list"; + if (!comments.length) list.innerHTML = '

暂无评论,来抢沙发吧

'; + + // 按 parent_id 分组:顶层评论在前,回复跟随其父评论 + const byId = new Map(comments.map((c) => [c.id, c])); + const topLevel = comments.filter((c) => c.parent_id == null); + const repliesByParent = {}; + comments.filter((c) => c.parent_id != null).forEach((r) => { + (repliesByParent[r.parent_id] = repliesByParent[r.parent_id] || []).push(r); + }); + + topLevel.forEach((c) => { + list.appendChild(buildCommentItem(el, articleId, c, byId, false)); + (repliesByParent[c.id] || []).forEach((r) => { + list.appendChild(buildCommentItem(el, articleId, r, byId, true)); + }); + }); + el.appendChild(list); + + // 评论 / 回复表单(仅好友或博主) + const canComment = state.user && (state.user.role === "friend" || state.user.role === "blogger"); + if (canComment) { + el.appendChild(buildCommentForm(el, articleId, null)); + } else { + const hint = document.createElement("p"); + hint.className = "muted comment-hint"; + if (state.user) { + hint.innerHTML = `仅好友或博主可以评论 `; + $("#hint-apply", hint).addEventListener("click", applyFriend); + } else { + hint.innerHTML = `登录并成为好友后可以评论 `; + $("#hint-login", hint).addEventListener("click", openLoginModal); + } + el.appendChild(hint); + } +} + +function buildCommentItem(el, articleId, comment, byId, isReply) { + const item = document.createElement("div"); + item.className = isReply ? "comment-item comment-reply" : "comment-item"; + const parent = comment.parent_id && byId.has(comment.parent_id) ? byId.get(comment.parent_id) : null; + const parentLabel = parent ? `回复 @${escapeHtml(parent.username || "匿名")}` : ""; + // 评论者头像:有头像显示图片,没有则显示空占位 + const avatarHtml = comment.avatar + ? `` + : ''; + item.innerHTML = ` +
+
+ ${avatarHtml} + ${escapeHtml(comment.username || "匿名")} + ${parentLabel} +
+ ${formatDate(comment.created_time)} +
+

${escapeHtml(comment.content)}

`; + + // 顶层评论显示“回复”按钮(仅好友/博主) + const canReply = state.user && (state.user.role === "friend" || state.user.role === "blogger"); + if (canReply && !isReply) { + const replyBtn = document.createElement("button"); + replyBtn.className = "btn btn-text btn-sm comment-reply-btn"; + replyBtn.textContent = "回复"; + replyBtn.addEventListener("click", () => { + let box = item.querySelector(".comment-reply-box"); + if (box) { box.remove(); return; } + box = document.createElement("div"); + box.className = "comment-reply-box"; + box.appendChild(buildCommentForm(el, articleId, comment)); + item.appendChild(box); + }); + item.appendChild(replyBtn); + } + return item; +} + +function buildCommentForm(el, articleId, parent) { + const form = document.createElement("div"); + form.className = "comment-form"; + const placeholder = parent + ? `回复 @${parent.username || "匿名"}…` + : "写下你的评论…"; + const btnText = parent ? "回复" : "发表评论"; + form.innerHTML = ` + + `; + const textarea = $(".comment-input", form); + const btn = $("button", form); + btn.addEventListener("click", async () => { + const content = textarea.value.trim(); + if (!content) { showToast("评论内容不能为空", "error"); return; } + if (content.length > 2000) { showToast("评论内容不能超过 2000 字", "error"); return; } + btn.disabled = true; btn.textContent = "发送中…"; + try { + const body = parent + ? { article_id: articleId, content, parent_id: parent.id } + : { article_id: articleId, content }; + await api("/api/comment/add", { method: "POST", body }); + showToast("评论成功", "success"); + await renderComments(el, articleId); + } catch (err) { + showToast(err.message, "error"); + } + }); + return form; +} \ No newline at end of file diff --git a/frontend/friend.js b/frontend/friend.js new file mode 100644 index 0000000..7664f96 --- /dev/null +++ b/frontend/friend.js @@ -0,0 +1,100 @@ +/* ===================================================================== + * 好友模块(friend.js) + * 好友申请 / 状态展示 / 博主审批弹窗。 + * ===================================================================== */ + +import { state } from "./state.js"; +import { escapeHtml, formatDate, openModal, showToast } from "./utils.js"; +import { api } from "./api.js"; +import { openLoginModal } from "./auth.js"; +import { renderAuthArea } from "./router.js"; + +export async function applyFriend() { + if (!state.user) { openLoginModal(); showToast("请先登录后再申请好友", "info"); return; } + try { + await api("/api/friend/apply", { method: "POST" }); + state.friendStatus = "pending"; + renderAuthArea(); + showToast("好友申请已提交,等待博主审批", "success"); + } catch (err) { + showToast(err.message, "error"); + } +} + +// 记录当前打开的审批弹窗引用,避免重复打开时弹窗叠加 +let manageModal = null; + +export function openFriendManageModal() { + // 先关闭上一次打开的审批弹窗 + if (manageModal) { try { manageModal.close(); } catch (e) { /* 忽略 */ } } + const body = document.createElement("div"); + body.innerHTML = '

加载中…

'; + manageModal = openModal({ title: "好友申请管理", content: body }); + renderApplications(body); +} + +export async function openFriendListModal() { + const body = document.createElement("div"); + body.innerHTML = '

加载中…

'; + const modal = openModal({ title: "好友列表", content: body }); + try { + const friends = await api("/api/friend/list"); + if (!friends.length) { body.innerHTML = '

还没有好友

'; return; } + body.innerHTML = friends.map((f) => ` +
+ ${f.avatar ? `` : ''} +
+ ${escapeHtml(f.username)} + ${escapeHtml(f.bio || `成为好友于 ${formatDate(f.friend_since)}`)} +
+
`).join(""); + } catch (err) { + body.innerHTML = ""; + showToast(err.message, "error"); + } +} + +export async function renderApplications(body) { + try { + const records = await api("/api/friend/applications"); + if (!records.length) { body.innerHTML = '

暂无申请记录

'; return; } + body.innerHTML = ""; + const STATUS_TEXT = { pending: "待审批", accepted: "已通过", rejected: "已拒绝" }; + for (const record of records) { + const row = document.createElement("div"); + row.className = "app-row"; + const statusText = STATUS_TEXT[record.status] || record.status; + row.innerHTML = ` + ${escapeHtml(record.username || "未知用户")} + ${escapeHtml(record.email || "")} + ${formatDate(record.created_time)} + ${statusText}`; + if (record.status === "pending") { + const approveBtn = document.createElement("button"); + approveBtn.className = "btn btn-primary btn-sm"; approveBtn.textContent = "通过"; + approveBtn.addEventListener("click", async () => { + try { + await api(`/api/friend/${record.id}/approve`, { method: "POST" }); + showToast("已通过好友申请", "success"); + await renderApplications(body); // 原地刷新当前弹窗内容,不再叠加新弹窗 + } catch (err) { showToast(err.message, "error"); } + }); + const rejectBtn = document.createElement("button"); + rejectBtn.className = "btn btn-outline btn-sm"; rejectBtn.textContent = "拒绝"; + rejectBtn.addEventListener("click", async () => { + try { + await api(`/api/friend/${record.id}/reject`, { method: "POST" }); + showToast("已拒绝好友申请"); + await renderApplications(body); // 原地刷新当前弹窗内容,不再叠加新弹窗 + } catch (err) { showToast(err.message, "error"); } + }); + row.appendChild(approveBtn); + row.appendChild(rejectBtn); + } + body.appendChild(row); + } + } catch (err) { + body.innerHTML = ""; + showToast(err.message, "error"); + } +} \ No newline at end of file diff --git a/frontend/icons/bilibili.svg b/frontend/icons/bilibili.svg new file mode 100644 index 0000000..12e09e2 --- /dev/null +++ b/frontend/icons/bilibili.svg @@ -0,0 +1 @@ +Bilibili \ No newline at end of file diff --git a/frontend/icons/donate.png b/frontend/icons/donate.png new file mode 100644 index 0000000..b80c1b8 Binary files /dev/null and b/frontend/icons/donate.png differ diff --git a/frontend/icons/github.svg b/frontend/icons/github.svg new file mode 100644 index 0000000..81920ca --- /dev/null +++ b/frontend/icons/github.svg @@ -0,0 +1 @@ +GitHub \ No newline at end of file diff --git a/frontend/icons/youtube.svg b/frontend/icons/youtube.svg new file mode 100644 index 0000000..9306b8e --- /dev/null +++ b/frontend/icons/youtube.svg @@ -0,0 +1 @@ +YouTube \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..69b4439 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,30 @@ + + + + + + + + + 孤竹居士的博客 + + + + +
+ +
+ +
+ + + +
+ + + + \ No newline at end of file diff --git a/frontend/main.js b/frontend/main.js new file mode 100644 index 0000000..c1d4034 --- /dev/null +++ b/frontend/main.js @@ -0,0 +1,31 @@ +/* ===================================================================== + * 入口(main.js) + * 挂载全局事件并完成首次渲染。 + * 各业务模块:api / auth / router / article / comment / manage / friend / + * markdown / state / utils + * ===================================================================== */ + +import { $ } from "./utils.js"; +import { refreshUser } from "./auth.js"; +import { navigate, render, renderAuthArea, renderNav } from "./router.js"; + +// 拦截站内 SPA 链接(带 data-link 的 ),并收起用户下拉菜单 +document.addEventListener("click", (e) => { + const link = e.target.closest("a[data-link]"); + if (link) { + e.preventDefault(); + navigate(link.getAttribute("href")); + return; + } + const dropdown = $("#user-dropdown"); + if (dropdown && !e.target.closest(".user-menu")) dropdown.classList.add("hidden"); +}); + +window.addEventListener("popstate", render); + +(async function init() { + renderNav(); + await refreshUser(); + renderAuthArea(); + render(); +})(); \ No newline at end of file diff --git a/frontend/manage.js b/frontend/manage.js new file mode 100644 index 0000000..b98ee74 --- /dev/null +++ b/frontend/manage.js @@ -0,0 +1,528 @@ +/* ===================================================================== + * 博主管理面板(manage.js) + * 发文 / 编辑 / 删除文章(简介编辑已移至主页简介处)。 + * ===================================================================== */ + +import { state } from "./state.js"; +import { $, escapeHtml, formatDate, openModal, showToast } from "./utils.js"; +import { api, uploadFile, uploadForm } from "./api.js"; +import { renderMarkdown } from "./markdown.js"; +import { navigate } from "./router.js"; +import { openLoginModal } from "./auth.js"; + +function insertAtCursor(input, text) { + const start = input.selectionStart ?? input.value.length; + const end = input.selectionEnd ?? input.value.length; + input.value = input.value.slice(0, start) + text + input.value.slice(end); + input.focus(); + const pos = start + text.length; + input.setSelectionRange(pos, pos); +} + +export async function renderManage(app) { + document.title = "博主管理 - 孤竹居士的博客"; + if (!state.user) { + app.innerHTML = ` +
+

!

+

请先登录

+ +
`; + $("#manage-login").addEventListener("click", openLoginModal); + return; + } + if (state.user.role !== "blogger") { + app.innerHTML = ` +
`; + return; + } + + app.innerHTML = ` +

博主管理

+
+
+

发布新文章

+
+ + + + +
+ +
+ + +
+ +
+ + +
+ + + + +
+ + + + + + + + +
+
+ +
+

我的文章

+

加载中…

+
+ +
+

项目管理(在线演示 / GitHub 链接)

+
+ + + + + + + + + +
+ + +
+ +

纯 HTML/CSS/JS(含 index.html)→ 自动开通在线演示;含后端代码 → 请填写 GitHub 链接

+ + +
+

项目列表

+

加载中…

+
+
`; + + // ---------- 封面 / 正文图片 / 预览 ---------- + const coverState = { url: "" }; + const coverFile = $("#cover-file", app); + const coverImg = $("#cover-img", app); + const coverPreview = $("#cover-preview", app); + const coverUrlInput = $("#cover-url", app); + + $("#btn-cover-upload", app).addEventListener("click", () => coverFile.click()); + coverFile.addEventListener("change", async () => { + const file = coverFile.files && coverFile.files[0]; + if (!file) return; + try { + const data = await uploadFile("/api/upload/article", file); + coverState.url = data.url; + coverPreview.classList.remove("hidden"); + coverImg.src = data.url; + coverUrlInput.value = data.url; + showToast("封面上传成功", "success"); + } catch (err) { showToast(err.message, "error"); } + coverFile.value = ""; + }); + $("#cover-remove", app).addEventListener("click", () => { + coverState.url = ""; + coverUrlInput.value = ""; + coverPreview.classList.add("hidden"); + coverImg.src = ""; + }); + coverUrlInput.addEventListener("input", () => { coverState.url = coverUrlInput.value.trim(); }); + + const contentInput = $("#art-content", app); + const insertFile = $("#insert-image-file", app); + $("#btn-insert-image", app).addEventListener("click", () => insertFile.click()); + insertFile.addEventListener("change", async () => { + const file = insertFile.files && insertFile.files[0]; + if (!file) return; + try { + const data = await uploadFile("/api/upload/article", file); + insertAtCursor(contentInput, `![](${data.url})`); + showToast("图片已插入正文", "success"); + } catch (err) { showToast(err.message, "error"); } + insertFile.value = ""; + }); + + // ---------- 正文视频插入(仅博主,mp4/webm) ---------- + const insertVideoFile = $("#insert-video-file", app); + $("#btn-insert-video", app).addEventListener("click", () => insertVideoFile.click()); + insertVideoFile.addEventListener("change", async () => { + const file = insertVideoFile.files && insertVideoFile.files[0]; + if (!file) return; + try { + const data = await uploadFile("/api/upload/video", file); + insertAtCursor(contentInput, `@[视频](${data.url})`); + showToast("视频已插入正文", "success"); + } catch (err) { showToast(err.message, "error"); } + insertVideoFile.value = ""; + }); + + // ---------- 正文文档插入(Word doc/docx)---------- + // 只能用于“我的生活 / 我的学习”分区的文章:上传时携带分区给后端校验(项目区不使用此功能) + const insertDocFile = $("#insert-doc-file", app); + $("#btn-insert-doc", app).addEventListener("click", () => insertDocFile.click()); + insertDocFile.addEventListener("change", async () => { + const file = insertDocFile.files && insertDocFile.files[0]; + if (!file) return; + try { + // 上传文档时携带文章分区:编辑已有文章用其分区,新文章默认 life(发布时仍可调整) + const category = editingCategory || "life"; + const data = await uploadFile("/api/upload/doc", file, { category }); + insertAtCursor(contentInput, `[📄 ${escapeHtml(data.filename)}](${data.url})`); + showToast("文档已插入正文", "success"); + } catch (err) { showToast(err.message, "error"); } + insertDocFile.value = ""; + }); + + const previewBox = $("#preview-box", app); + $("#btn-preview-toggle", app).addEventListener("click", () => { + previewBox.classList.toggle("hidden"); + if (!previewBox.classList.contains("hidden")) { + $("#preview-content", app).innerHTML = renderMarkdown(contentInput.value); + } + }); + + // ---------- 发布 / 编辑 ---------- + let editingId = null; + let editingCategory = "life"; + let editingVisibility = "public"; + const setEditing = (article) => { + editingId = article ? article.id : null; + editingCategory = article ? (article.category || "life") : "life"; + editingVisibility = article ? (article.visibility || "public") : "public"; + $("#form-title", app).textContent = article ? "编辑文章" : "发布新文章"; + $("#btn-publish", app).textContent = article ? "保存修改" : "发布文章"; + $("#btn-cancel-edit", app).classList.toggle("hidden", !article); + if (article) { + $("#art-title", app).value = article.title; + contentInput.value = article.content; + coverState.url = article.cover || ""; + coverUrlInput.value = article.cover || ""; + if (article.cover) { + coverPreview.classList.remove("hidden"); + coverImg.src = article.cover; + } else { + coverPreview.classList.add("hidden"); + coverImg.src = ""; + } + } + }; + + $("#btn-cancel-edit", app).addEventListener("click", () => { + $("#article-form", app).reset(); + setEditing(null); + coverState.url = ""; + coverUrlInput.value = ""; + coverPreview.classList.add("hidden"); + coverImg.src = ""; + }); + + $("#btn-publish", app).addEventListener("click", () => { + const title = $("#art-title", app).value.trim(); + const content = contentInput.value.trim(); + if (!title) { showToast("请填写文章标题", "error"); return; } + if (!content) { showToast("请填写文章正文", "error"); return; } + + // 发布管理弹窗:选择发布分区与可见范围后再确认 + const modalContent = document.createElement("div"); + modalContent.innerHTML = ` +

选择文章发布到的分区,以及谁可以查看。

+
+ +
+ + +
+ +
+ + +
+
`; + const modal = openModal({ title: editingId ? "保存修改" : "发布管理", content: modalContent }); + const actions = document.createElement("div"); + actions.className = "modal-actions"; + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "btn btn-outline"; cancelBtn.textContent = "取消"; + cancelBtn.addEventListener("click", () => modal.close()); + const confirmBtn = document.createElement("button"); + confirmBtn.type = "button"; + confirmBtn.id = "pub-confirm"; + confirmBtn.className = "btn btn-primary"; confirmBtn.textContent = editingId ? "保存修改" : "确认发布"; + confirmBtn.addEventListener("click", async () => { + const category = modalContent.querySelector('input[name="pub-category"]:checked').value; + const visibility = modalContent.querySelector('input[name="pub-visibility"]:checked').value; + confirmBtn.disabled = true; confirmBtn.textContent = "发布中…"; + try { + const body = { title, content, cover: coverState.url || null, visibility, category }; + let newId = editingId; + if (editingId) { + await api(`/api/article/${editingId}`, { method: "PUT", body }); + showToast("修改已保存", "success"); + } else { + const created = await api("/api/article/add", { method: "POST", body }); + newId = created.id; + showToast("发布成功", "success"); + } + modal.close(); + $("#article-form", app).reset(); + setEditing(null); + coverState.url = ""; + coverUrlInput.value = ""; + coverPreview.classList.add("hidden"); + coverImg.src = ""; + previewBox.classList.add("hidden"); + if (newId) navigate(`/${category}/${newId}`); + else await loadMyArticles(); + } catch (err) { showToast(err.message, "error"); } + confirmBtn.disabled = false; confirmBtn.textContent = editingId ? "保存修改" : "确认发布"; + }); + actions.appendChild(cancelBtn); + actions.appendChild(confirmBtn); + modalContent.appendChild(actions); + }); + + // ---------- 我的文章:列表 + 编辑 / 删除 ---------- + const myArticlesEl = $("#my-articles", app); + async function loadMyArticles() { + try { + const data = await api("/api/article/list?page=1&page_size=100"); + const mine = data.items.filter((a) => a.author_id === state.user.id); + if (!mine.length) { myArticlesEl.innerHTML = '

还没有发布过文章

'; return; } + myArticlesEl.innerHTML = ""; + mine.forEach((a) => { + const row = document.createElement("div"); + row.className = "my-article-row"; + row.innerHTML = ` + ${a.cover ? `` : ''} + + ${a.visibility === "friend" ? '好友专属' : '公开'} +
+ + +
`; + $(".my-article-edit", row).addEventListener("click", async () => { + try { + const detail = await api(`/api/article/${a.id}`); + setEditing(detail); + window.scrollTo({ top: 0, behavior: "smooth" }); + } catch (err) { showToast(err.message, "error"); } + }); + $(".my-article-delete", row).addEventListener("click", () => { + confirmDeleteArticle(a.id, a.title); + }); + myArticlesEl.appendChild(row); + }); + } catch (err) { + myArticlesEl.innerHTML = '

加载失败

'; + showToast(err.message, "error"); + } + } + + function confirmDeleteArticle(id, title) { + const content = document.createElement("div"); + content.innerHTML = `

确定删除文章「${escapeHtml(title)}」吗?删除后评论与点赞将一并删除,且不可恢复。

`; + const modal = openModal({ title: "删除确认", content }); + const actions = document.createElement("div"); + actions.className = "modal-actions"; + const cancelBtn = document.createElement("button"); + cancelBtn.className = "btn btn-outline"; cancelBtn.textContent = "取消"; + cancelBtn.addEventListener("click", () => modal.close()); + const okBtn = document.createElement("button"); + okBtn.className = "btn btn-danger"; okBtn.textContent = "确认删除"; + okBtn.addEventListener("click", async () => { + okBtn.disabled = true; okBtn.textContent = "删除中…"; + try { + await api(`/api/article/${id}`, { method: "DELETE" }); + modal.close(); + showToast("删除成功", "success"); + await loadMyArticles(); + } catch (err) { + showToast(err.message, "error"); + okBtn.disabled = false; okBtn.textContent = "确认删除"; + } + }); + actions.appendChild(cancelBtn); + actions.appendChild(okBtn); + content.appendChild(actions); + } + + + // ---------- 项目管理(L0 静态托管) ---------- + let editingProjectId = null; + let editingProjVisibility = "public"; + let selectedZip = null; + const projName = $("#proj-name", app); + const projDesc = $("#proj-desc", app); + const projTech = $("#proj-tech", app); + const projGithub = $("#proj-github", app); + const projFileInput = $("#proj-file", app); + const projFileNameEl = $("#proj-file-name", app); + + $("#btn-proj-upload", app).addEventListener("click", () => projFileInput.click()); + projFileInput.addEventListener("change", () => { + selectedZip = projFileInput.files && projFileInput.files[0]; + projFileNameEl.textContent = selectedZip ? selectedZip.name : ""; + }); + + function resetProjectForm() { + editingProjectId = null; + editingProjVisibility = "public"; + selectedZip = null; + $("#project-form", app).reset(); + projFileNameEl.textContent = ""; + $("#btn-proj-save", app).textContent = "添加项目"; + $("#btn-proj-cancel", app).classList.add("hidden"); + $("#proj-form-title", app).textContent = "项目管理(在线演示 / GitHub 链接)"; + } + + $("#btn-proj-cancel", app).addEventListener("click", resetProjectForm); + + $("#btn-proj-save", app).addEventListener("click", async () => { + const name = projName.value.trim(); + if (!name) { showToast("请填写项目名称", "error"); return; } + + // 保存前弹窗选择可见范围(对齐文章发布流程) + const modalContent = document.createElement("div"); + modalContent.innerHTML = ` +

选择谁可以查看这个项目。

+
+ +
+ + +
+
`; + const modal = openModal({ title: editingProjectId ? "保存修改" : "发布管理", content: modalContent }); + const actions = document.createElement("div"); + actions.className = "modal-actions"; + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "btn btn-outline"; cancelBtn.textContent = "取消"; + cancelBtn.addEventListener("click", () => modal.close()); + const confirmBtn = document.createElement("button"); + confirmBtn.type = "button"; + confirmBtn.className = "btn btn-primary"; confirmBtn.textContent = editingProjectId ? "保存修改" : "确认添加"; + confirmBtn.addEventListener("click", async () => { + const visibility = modalContent.querySelector('input[name="proj-visibility"]:checked').value; + confirmBtn.disabled = true; confirmBtn.textContent = "保存中…"; + const fields = { + name, + description: projDesc.value.trim(), + tech: projTech.value.trim(), + github_url: projGithub.value.trim(), + visibility, + }; + try { + if (editingProjectId) { + await uploadForm(`/api/project/${editingProjectId}`, fields, selectedZip, "PUT"); + showToast("项目已更新", "success"); + } else { + await uploadForm("/api/project/add", fields, selectedZip); + showToast("项目已添加", "success"); + } + modal.close(); + resetProjectForm(); + await loadProjects(); + } catch (err) { showToast(err.message, "error"); } + confirmBtn.disabled = false; confirmBtn.textContent = editingProjectId ? "保存修改" : "确认添加"; + }); + actions.appendChild(cancelBtn); + actions.appendChild(confirmBtn); + modalContent.appendChild(actions); + }); + + async function loadProjects() { + const listEl = $("#proj-list", app); + let items = []; + try { items = await api("/api/project/list"); } + catch (err) { listEl.innerHTML = '

加载失败

'; showToast(err.message, "error"); return; } + if (!items.length) { listEl.innerHTML = '

还没有项目,添加一个试试

'; return; } + listEl.innerHTML = ""; + items.forEach((p) => { + const row = document.createElement("div"); + row.className = "my-article-row"; + const typeBadge = p.project_type === "static" + ? '在线演示' + : 'GitHub 链接'; + const visBadge = p.visibility === "friend" + ? '好友专属' + : '公开'; + row.innerHTML = ` + + ${visBadge} + ${typeBadge} +
+ + +
`; + $(".proj-edit", row).addEventListener("click", () => { + editingProjectId = p.id; + editingProjVisibility = p.visibility || "public"; + projName.value = p.name; + projDesc.value = p.description || ""; + projTech.value = p.tech || ""; + projGithub.value = p.github_url || ""; + selectedZip = null; projFileNameEl.textContent = ""; + $("#btn-proj-save", app).textContent = "保存修改"; + $("#btn-proj-cancel", app).classList.remove("hidden"); + $("#proj-form-title", app).textContent = `编辑项目:${p.name}`; + window.scrollTo({ top: 0, behavior: "smooth" }); + }); + $(".proj-delete", row).addEventListener("click", () => confirmDeleteProject(p)); + listEl.appendChild(row); + }); + } + + function confirmDeleteProject(p) { + const content = document.createElement("div"); + content.innerHTML = `

确定删除项目「${escapeHtml(p.name)}」吗?在线演示与下载文件将一并删除。

`; + const modal = openModal({ title: "删除确认", content }); + const actions = document.createElement("div"); + actions.className = "modal-actions"; + const cancelBtn = document.createElement("button"); + cancelBtn.className = "btn btn-outline"; cancelBtn.textContent = "取消"; + cancelBtn.addEventListener("click", () => modal.close()); + const okBtn = document.createElement("button"); + okBtn.className = "btn btn-danger"; okBtn.textContent = "确认删除"; + okBtn.addEventListener("click", async () => { + okBtn.disabled = true; okBtn.textContent = "删除中…"; + try { + await api(`/api/project/${p.id}`, { method: "DELETE" }); + modal.close(); + showToast("项目已删除", "success"); + await loadProjects(); + } catch (err) { + showToast(err.message, "error"); + okBtn.disabled = false; okBtn.textContent = "确认删除"; + } + }); + actions.appendChild(cancelBtn); + actions.appendChild(okBtn); + content.appendChild(actions); + } + + await loadMyArticles(); + await loadProjects(); +} \ No newline at end of file diff --git a/frontend/markdown.js b/frontend/markdown.js new file mode 100644 index 0000000..6562760 --- /dev/null +++ b/frontend/markdown.js @@ -0,0 +1,181 @@ +/* ===================================================================== + * Markdown 轻量渲染器(markdown.js) + * --------------------------------------------------------------------- + * 【这个文件是干什么的?】 + * 博客文章正文是用 Markdown 语法写的(# 标题、**加粗**、```代码块```), + * 但浏览器只认 HTML。这个文件负责把 Markdown 文本"翻译"成 HTML 字符串, + * 再交给其他模块(article.js 渲染文章正文、manage.js 做编辑预览)插入页面。 + * + * 【整体思路:分两层处理】 + * 第 1 层(安全):先把整篇文本用 escapeHtml 转义, + * 把 < > & 等特殊字符变成 < > & 这样的"HTML 实体"。 + * 这样用户写的内容永远不会被当成 HTML 标签执行 —— 这是防 XSS 攻击的关键。 + * 第 2 层(排版):把转义后的文本按"块级元素"逐行解析 + * (标题 / 段落 / 列表 / 引用 / 分隔线 / 代码块), + * 每一行内部再调用 inlineMarkdown 处理"行内元素"(加粗 / 斜体 / 链接等)。 + * + * 【两个函数的分工】 + * - inlineMarkdown(text):只管"一行文本内部"的语法替换(行内元素) + * - renderMarkdown(md):入口函数,负责整篇的分行、分块、组装(块级元素) + * ===================================================================== */ + +import { escapeHtml, safeUrl } from "./utils.js"; + +/** + * 行内 Markdown 渲染 + * 只处理"行内"语法(不跨行),比如: + * **加粗** *斜体* `行内代码` [链接文字](URL) ![图片描述](URL) + * 原理:用一串正则表达式依次"查找 - 替换",把 Markdown 写法换成 HTML 标签。 + * + * 注意:传入的 text 已经过 escapeHtml 转义(在 renderMarkdown 里完成), + * 所以这里生成的内容不会携带危险标签;链接 / 图片地址还会再过一次 + * safeUrl 协议白名单(只允许 http/https 等安全协议),双保险防 XSS。 + */ +function inlineMarkdown(text) { + return text + // ---- 图片:![替代文字](图片地址) -> ---- + // 正则逐段拆解: + // !\[([^\]]*)\] 匹配 "![" + 任意个"不是 ] 的字符"(就是替代文字,存入分组1) + // \(([^)\s]+)\) 匹配 "(" + 任意个"不是 ) 和空格 的字符"(就是 URL,存入分组2) + // /g 标志 = 全局替换,把整行里所有图片语法都处理掉 + .replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (match, alt, url) => { + // safeUrl(url, "image"):按"图片"规则校验地址是否安全 + const safe = safeUrl(url, "image"); + // 安全才输出 ;不安全直接返回空字符串(丢弃这张图) + // loading="lazy":图片滚动到视野内才加载,省流量 + return safe ? `${alt}` : ""; + }) + // ---- 视频:@[说明文字](视频地址) ->