From c61193f2af247c59cac35c76c0c445c808b5a24e Mon Sep 17 00:00:00 2001 From: guzhujushi Date: Sat, 22 Aug 2026 22:28:41 +0800 Subject: [PATCH] Initial commit: MyBlog full stack blog --- .env.example | 193 +++++++ .gitignore | 18 + .idea/MyBlog.iml | 12 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + .idea/workspace.xml | 54 ++ README.md | 160 ++++++ backend/__init__.py | 1 + backend/auth.py | 97 ++++ backend/database.py | 61 ++ backend/email.py | 55 ++ backend/main.py | 47 ++ backend/models.py | 174 ++++++ backend/routers/__init__.py | 34 ++ backend/routers/article.py | 307 ++++++++++ backend/routers/comment.py | 127 +++++ backend/routers/deps.py | 41 ++ backend/routers/email.py | 161 ++++++ backend/routers/friend.py | 191 +++++++ backend/routers/ipwatch.py | 264 +++++++++ backend/routers/like.py | 85 +++ backend/routers/password.py | 70 +++ backend/routers/project.py | 412 ++++++++++++++ backend/routers/upload.py | 193 +++++++ backend/routers/user.py | 199 +++++++ backend/routers/wasm.py | 48 ++ backend/schemas.py | 222 ++++++++ backend/security.py | 119 ++++ backend/seed.py | 50 ++ backend/services/__init__.py | 0 backend/services/wasm_builder.py | 309 ++++++++++ deploy/DEPLOY.md | 476 ++++++++++++++++ deploy/deploy.conf | 5 + deploy/install_javy.sh | 42 ++ deploy/ipwatch/README.md | 39 ++ deploy/ipwatch/install.ps1 | 33 ++ deploy/ipwatch/ipwatch.conf.example | 7 + deploy/ipwatch/report.ps1 | 77 +++ deploy/ipwatch/uninstall.ps1 | 4 + deploy/myblog.service | 49 ++ deploy/nginx.conf | 106 ++++ deploy/precompile_wasm.sh | 17 + deploy/update.ps1 | 94 ++++ deploy/update_server.sh | 127 +++++ frontend/api.js | 63 +++ frontend/article.js | 379 +++++++++++++ frontend/auth.js | 210 +++++++ frontend/comment.js | 126 +++++ frontend/friend.js | 100 ++++ frontend/icons/bilibili.svg | 1 + frontend/icons/donate.png | Bin 0 -> 151834 bytes frontend/icons/github.svg | 1 + frontend/icons/youtube.svg | 1 + frontend/index.html | 30 + frontend/main.js | 31 + frontend/manage.js | 528 ++++++++++++++++++ frontend/markdown.js | 181 ++++++ frontend/router.js | 223 ++++++++ frontend/state.js | 52 ++ frontend/style.css | 464 +++++++++++++++ frontend/utils.js | 83 +++ myblog.tar.gz | Bin 0 -> 60764 bytes requirements.txt | 17 + 64 files changed, 7290 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .idea/MyBlog.iml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 .idea/workspace.xml create mode 100644 README.md create mode 100644 backend/__init__.py create mode 100644 backend/auth.py create mode 100644 backend/database.py create mode 100644 backend/email.py create mode 100644 backend/main.py create mode 100644 backend/models.py create mode 100644 backend/routers/__init__.py create mode 100644 backend/routers/article.py create mode 100644 backend/routers/comment.py create mode 100644 backend/routers/deps.py create mode 100644 backend/routers/email.py create mode 100644 backend/routers/friend.py create mode 100644 backend/routers/ipwatch.py create mode 100644 backend/routers/like.py create mode 100644 backend/routers/password.py create mode 100644 backend/routers/project.py create mode 100644 backend/routers/upload.py create mode 100644 backend/routers/user.py create mode 100644 backend/routers/wasm.py create mode 100644 backend/schemas.py create mode 100644 backend/security.py create mode 100644 backend/seed.py create mode 100644 backend/services/__init__.py create mode 100644 backend/services/wasm_builder.py create mode 100644 deploy/DEPLOY.md create mode 100644 deploy/deploy.conf create mode 100644 deploy/install_javy.sh create mode 100644 deploy/ipwatch/README.md create mode 100644 deploy/ipwatch/install.ps1 create mode 100644 deploy/ipwatch/ipwatch.conf.example create mode 100644 deploy/ipwatch/report.ps1 create mode 100644 deploy/ipwatch/uninstall.ps1 create mode 100644 deploy/myblog.service create mode 100644 deploy/nginx.conf create mode 100644 deploy/precompile_wasm.sh create mode 100644 deploy/update.ps1 create mode 100644 deploy/update_server.sh create mode 100644 frontend/api.js create mode 100644 frontend/article.js create mode 100644 frontend/auth.js create mode 100644 frontend/comment.js create mode 100644 frontend/friend.js create mode 100644 frontend/icons/bilibili.svg create mode 100644 frontend/icons/donate.png create mode 100644 frontend/icons/github.svg create mode 100644 frontend/icons/youtube.svg create mode 100644 frontend/index.html create mode 100644 frontend/main.js create mode 100644 frontend/manage.js create mode 100644 frontend/markdown.js create mode 100644 frontend/router.js create mode 100644 frontend/state.js create mode 100644 frontend/style.css create mode 100644 frontend/utils.js create mode 100644 myblog.tar.gz create mode 100644 requirements.txt 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 0000000000000000000000000000000000000000..b80c1b8ce416926debc59c9716659e6c749af045 GIT binary patch literal 151834 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ⅆ?J>6j$39e`U%@73V+?S7#$Edm6} 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 literal 0 HcmV?d00001 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}` : ""; + }) + // ---- 视频:@[说明文字](视频地址) ->
+ +