Initial commit: MyBlog full stack blog

This commit is contained in:
2026-08-22 22:28:41 +08:00
commit c61193f2af
64 changed files with 7290 additions and 0 deletions
+193
View File
@@ -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.dbSQLite 文件)
# 保持默认即可;如需换路径:
# 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_PORT465=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.shGitHub 下载约 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
+18
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/MyBlog.iml" filepath="$PROJECT_DIR$/.idea/MyBlog.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+54
View File
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ChangeListManager">
<list default="true" id="778f98f7-2b5a-4dad-a05f-40e57299ba19" name="更改" comment="" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo"><![CDATA[{
"associatedIndex": 2,
"fromUser": false
}]]></component>
<component name="ProjectId" id="3HuV3RyNYVDFZlEluVuNMQs38Ui" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ModuleVcsDetector.initialDetectionPerformed": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"codeWithMe.voiceChat.enabledByDefault": "false",
"git-widget-placeholder": "main",
"last_opened_file_path": "D:/MyBlog",
"nodejs_package_manager_path": "npm"
}
}]]></component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-python-sdk-245168a56dbd-c2ffad84badb-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-261.25134.203" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="默认任务">
<changelist id="778f98f7-2b5a-4dad-a05f-40e57299ba19" name="更改" comment="" />
<created>1786717740321</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1786717740321</updated>
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
</project>
+160
View File
@@ -0,0 +1,160 @@
# 孤竹居士的个人博客
部署在阿里云 Ubuntu 上的个人全栈博客系统:原生前端 SPA + FastAPI + SQLite。
## 技术架构
| 层 | 技术 |
| --- | --- |
| 服务器 | Ubuntu Linux + Nginx |
| 前端 | 原生 HTML + CSS + JavaScriptSPAHistory API 路由) |
| 后端 | Python FastAPI(仅监听 127.0.0.1:8080 |
| 数据库 | SQLite + SQLAlchemy ORM |
| 认证 | JWT Tokenbcrypt 存储密码哈希) |
| 文件 | Nginx 静态访问 uploads 目录 |
- Nginx80HTTP/ 443HTTPS,待配置);`/api/*` 反代到 `127.0.0.1:8080``/uploads/*` 静态映射。
- 端口:22 SSH、80 HTTP、443 HTTPS、25565 Minecraft(独立运行,不占用博客端口)。
## 目录结构
```text
blog/
├── frontend/ # 前端源码(原生 SPAES 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. Nginx80 端口托管 `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`:好友申请创建时间,用于博主审批列表展示申请时间。
+1
View File
@@ -0,0 +1 @@
"""MyBlog 后端包。"""
+97
View File
@@ -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), # 用户 idsubject
"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 <token> 解析并返回当前用户。"""
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
+61
View File
@@ -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_threadFastAPI 多线程处理请求时会跨线程复用会话
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}")
+55
View File
@@ -0,0 +1,55 @@
"""
邮件发送模块。
通过 SMTP 发送验证码邮件,配置项从 .env 读取:
- SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_AUTH_CODE / EMAIL_FROM
163 邮箱:smtp.163.com,端口 465SSL),密码处填写授权码。
"""
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()
+47
View File
@@ -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="个人博客系统后端 APIFastAPI + 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)
+174
View File
@@ -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="创建时间")
+34
View File
@@ -0,0 +1,34 @@
"""
API 路由包。
各业务路由(认证、文章、评论、点赞、好友、邮箱验证码、密码重置、上传、
项目、WASM 预编译)在各自模块中实现,并在此统一汇总挂载。
"""
from fastapi import APIRouter
from .article import router as article_router
from .comment import router as comment_router
from .email import router as email_router
from .friend import router as friend_router
from .ipwatch import router as ipwatch_router
from .like import router as like_router
from .password import router as password_router
from .project import router as project_router
from .upload import router as upload_router
from .user import router as user_router
from .wasm import router as wasm_router
# 统一业务路由
api_router = APIRouter()
api_router.include_router(user_router)
api_router.include_router(article_router)
api_router.include_router(comment_router)
api_router.include_router(friend_router)
api_router.include_router(like_router)
api_router.include_router(email_router)
api_router.include_router(password_router)
api_router.include_router(project_router)
api_router.include_router(upload_router)
api_router.include_router(ipwatch_router)
api_router.include_router(wasm_router)
+307
View File
@@ -0,0 +1,307 @@
"""
文章路由。
接口(统一响应格式 {success, data, message}):
- POST /api/article/add 发布文章(仅 blogger),支持封面与 public / friend 可见性
- GET /api/article/list 文章列表(分页):所有用户可见;好友文章对游客仅展示标题与封面
- GET /api/article/{id} 文章详情:好友文章对游客仅返回标题与封面,不返回正文
- PUT /api/article/{id} 更新文章(仅 blogger,字段可选)
- DELETE /api/article/{id} 删除文章(仅 blogger,级联删除评论与点赞)
"""
import os
import re
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import or_
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import PROJECT_ROOT, get_db
from ..models import (
ARTICLE_CATEGORY_LIFE,
ARTICLE_CATEGORY_STUDY,
ROLE_BLOGGER,
VISIBILITY_FRIEND,
VISIBILITY_PUBLIC,
Article,
User,
)
from ..schemas import ArticleCreate, ArticleUpdate, UnifiedResponse
from .deps import can_read_friend_article, get_optional_user
router = APIRouter(prefix="/api/article", tags=["article"])
# 上传根目录(与 routers/upload.py 解析方式一致,可通过 .env 的 UPLOADS_DIR 覆盖)
UPLOADS_ROOT = Path(os.getenv("UPLOADS_DIR") or str(PROJECT_ROOT / "uploads"))
# 允许清理的上传子目录白名单(严格限定,防止路径穿越)
UPLOAD_SUBDIRS = {"avatar", "article", "project"}
def _resolve_upload_file(subdir: str, filename: str) -> Optional[Path]:
"""把上传子目录与文件名解析为安全路径;子目录不在白名单或文件名含路径分隔符时返回 None。"""
if subdir not in UPLOAD_SUBDIRS:
return None
if not filename or "/" in filename or "\\" in filename or ".." in filename:
return None
target = (UPLOADS_ROOT / subdir / filename).resolve()
root = UPLOADS_ROOT.resolve()
if root not in target.parents:
return None
return target
def _collect_upload_paths(article: Article) -> list:
"""收集文章关联的上传文件路径:封面 + 正文 Markdown 图片(仅站内 /uploads/ 路径)。"""
paths = []
seen = set()
def add_if_safe(subdir: str, filename: str) -> None:
if not filename or filename in seen:
return
path = _resolve_upload_file(subdir, filename)
if path is not None:
seen.add(filename)
paths.append(path)
def parse_url(url: str) -> None:
# 仅处理站内路径:/uploads/<子目录>/<随机文件名>
parts = (url or "").split("/")
if len(parts) == 4 and parts[1] == "uploads":
add_if_safe(parts[2], parts[3])
parse_url(article.cover or "")
# 同时解析正文图片 ![]() 与视频 @[视频]() 两种站内资源引用
for match in re.finditer(r"(?:!\[[^\]]*\]|@\[[^\]]*\]|\[[^\]]*\])\(([^)\s]+)\)", article.content or ""):
parse_url(match.group(1).strip())
return paths
def _delete_upload_files(paths: list) -> None:
"""尽力删除文件:文件不存在或删除失败都不影响文章删除结果(不阻断主流程)。"""
for path in paths:
try:
path.unlink(missing_ok=True)
except OSError:
# 文件被占用 / 权限不足等场景:跳过,避免文章删除失败
pass
def _check_visibility(visibility: str) -> None:
"""校验文章可见性取值。"""
if visibility not in (VISIBILITY_PUBLIC, VISIBILITY_FRIEND):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="visibility 只能为 public 或 friend")
def _check_category(category: str) -> None:
"""校验文章分区取值(life 生活 / study 学习)。"""
if category not in (ARTICLE_CATEGORY_LIFE, ARTICLE_CATEGORY_STUDY):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="category 只能为 life 或 study")
def _require_blogger(user: User) -> None:
"""校验当前用户是否为博主。"""
if user.role != ROLE_BLOGGER:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以操作文章")
@router.post("/add", response_model=UnifiedResponse)
def add_article(
payload: ArticleCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""发布文章:仅博主可操作,支持封面与 public / friend 两种可见性。"""
_require_blogger(current_user)
_check_visibility(payload.visibility)
category = payload.category or ARTICLE_CATEGORY_LIFE
_check_category(category)
title = payload.title.strip()
content = payload.content.strip()
cover = (payload.cover or "").strip() or None
if not title:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文章标题不能为空")
if not content:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文章内容不能为空")
article = Article(
title=title,
content=content,
cover=cover,
visibility=payload.visibility,
category=category,
author_id=current_user.id,
)
db.add(article)
db.commit()
db.refresh(article)
data = {
"id": article.id,
"title": article.title,
"cover": article.cover,
"visibility": article.visibility,
"category": article.category or ARTICLE_CATEGORY_LIFE,
"created_time": article.created_time,
}
return UnifiedResponse(success=True, data=data, message="发布成功")
@router.get("/list", response_model=UnifiedResponse)
def list_articles(
page: int = Query(1, ge=1, description="页码,从 1 开始"),
page_size: int = Query(10, ge=1, le=100, description="每页数量"),
category: Optional[str] = Query(None, description="分区过滤:life / study(不传返回全部分区)"),
current_user: Optional[User] = Depends(get_optional_user),
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""文章列表(分页):公开文章所有人可见,好友文章对游客仅展示标题与封面。"""
privileged = can_read_friend_article(current_user)
query = db.query(Article).order_by(Article.created_time.desc(), Article.id.desc())
if category:
_check_category(category)
if category == ARTICLE_CATEGORY_LIFE:
# 旧数据 category 为空视为生活区
query = query.filter(or_(Article.category == category, Article.category.is_(None)))
else:
query = query.filter(Article.category == category)
total = query.count()
articles = query.offset((page - 1) * page_size).limit(page_size).all()
items = []
for a in articles:
if a.visibility == VISIBILITY_FRIEND and not privileged:
# 游客/匿名用户:好友文章仅展示标题与封面
items.append({
"id": a.id,
"title": a.title,
"cover": a.cover,
"visibility": a.visibility,
"category": a.category or ARTICLE_CATEGORY_LIFE,
})
continue
items.append({
"id": a.id,
"title": a.title,
"cover": a.cover,
"visibility": a.visibility,
"category": a.category or ARTICLE_CATEGORY_LIFE,
"author_id": a.author_id,
"author_username": a.author.username if a.author else None,
"created_time": a.created_time,
})
data = {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
}
return UnifiedResponse(success=True, data=data, message="获取成功")
@router.get("/{article_id}", response_model=UnifiedResponse)
def get_article(
article_id: int,
current_user: Optional[User] = Depends(get_optional_user),
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""文章详情:好友文章对游客仅返回标题与封面,不返回正文。"""
article = db.query(Article).filter(Article.id == article_id).first()
if article is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文章不存在")
if article.visibility == VISIBILITY_FRIEND and not can_read_friend_article(current_user):
data = {
"id": article.id,
"title": article.title,
"cover": article.cover,
"visibility": article.visibility,
"category": article.category or ARTICLE_CATEGORY_LIFE,
"content": None,
}
return UnifiedResponse(success=True, data=data, message="该文章仅好友可见,正文不可查看")
data = {
"id": article.id,
"title": article.title,
"cover": article.cover,
"content": article.content,
"visibility": article.visibility,
"category": article.category or ARTICLE_CATEGORY_LIFE,
"author_id": article.author_id,
"author_username": article.author.username if article.author else None,
"created_time": article.created_time,
}
return UnifiedResponse(success=True, data=data, message="获取成功")
@router.put("/{article_id}", response_model=UnifiedResponse)
def update_article(
article_id: int,
payload: ArticleUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""更新文章:仅博主可操作;仅更新传入的字段,未传字段保持不变。"""
_require_blogger(current_user)
article = db.query(Article).filter(Article.id == article_id).first()
if article is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文章不存在")
if payload.visibility is not None:
_check_visibility(payload.visibility)
article.visibility = payload.visibility
if payload.category is not None:
_check_category(payload.category)
article.category = payload.category
if payload.title is not None:
title = payload.title.strip()
if not title:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文章标题不能为空")
article.title = title
if payload.content is not None:
content = payload.content.strip()
if not content:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文章内容不能为空")
article.content = content
if payload.cover is not None:
article.cover = payload.cover.strip() or None
db.commit()
db.refresh(article)
data = {
"id": article.id,
"title": article.title,
"cover": article.cover,
"visibility": article.visibility,
"category": article.category or ARTICLE_CATEGORY_LIFE,
}
return UnifiedResponse(success=True, data=data, message="更新成功")
@router.delete("/{article_id}", response_model=UnifiedResponse)
def delete_article(
article_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""删除文章:仅博主可操作;关联评论与点赞随文章级联删除。"""
_require_blogger(current_user)
article = db.query(Article).filter(Article.id == article_id).first()
if article is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文章不存在")
# 删除前收集关联的上传文件(封面与正文图片),防止删除文章后留下孤儿文件
upload_paths = _collect_upload_paths(article)
db.delete(article)
db.commit()
# 数据删除成功后再清理文件(失败不阻断,避免因文件权限问题导致文章无法删除)
_delete_upload_files(upload_paths)
return UnifiedResponse(success=True, data={"id": article_id}, message="删除成功")
+127
View File
@@ -0,0 +1,127 @@
"""
评论路由。
接口(统一响应格式 {success, data, message}):
- POST /api/comment/add 发表评论或回复(仅好友/博主;content 最长 2000 字;parent_id 指定回复对象)
- GET /api/comment/list 评论列表(可见性与文章一致;按时间升序,返回 parent_id 供前端分组展示)
权限:
- 游客不可评论;visitor 角色不可评论(需先成为好友)。
- 好友文章仅好友/博主可查看评论。
"""
import os
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import (
ROLE_BLOGGER,
ROLE_FRIEND,
VISIBILITY_FRIEND,
Article,
Comment,
User,
)
from ..schemas import CommentCreate, UnifiedResponse
from ..security import action_limiter, get_client_ip
from .deps import can_read_friend_article, get_optional_user
router = APIRouter(prefix="/api/comment", tags=["comment"])
# 评论内容长度限制(可通过 .env 的 COMMENT_MAX_LENGTH 调整,默认 2000
COMMENT_MAX_LENGTH = int(os.getenv("COMMENT_MAX_LENGTH") or "2000")
@router.post("/add", response_model=UnifiedResponse)
def add_comment(
payload: CommentCreate,
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""发表评论或回复:仅好友或博主可评论,回复时 parent_id 必须属于同一篇文章。"""
# 写操作限流(IP 维度):防止好友账号刷屏
if action_limiter.is_blocked(get_client_ip(request)):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="操作过于频繁,请稍后再试")
if current_user.role not in (ROLE_FRIEND, ROLE_BLOGGER):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有好友或博主可以评论")
article = db.query(Article).filter(Article.id == payload.article_id).first()
if article is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文章不存在")
content = payload.content.strip()
if not content:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="评论内容不能为空")
if len(content) > COMMENT_MAX_LENGTH:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"评论内容不能超过 {COMMENT_MAX_LENGTH}")
# 回复校验:父评论必须存在且属于同一篇文章,且不允许回复自身(无自身场景,防脏数据)
parent_id = payload.parent_id
if parent_id is not None:
parent = db.query(Comment).filter(Comment.id == parent_id).first()
if parent is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="回复的评论不存在")
if parent.article_id != article.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="回复的评论不属于该文章")
comment = Comment(
article_id=payload.article_id,
user_id=current_user.id,
parent_id=parent_id,
content=content,
)
db.add(comment)
db.commit()
db.refresh(comment)
action_limiter.hit(get_client_ip(request))
data = {
"id": comment.id,
"article_id": comment.article_id,
"user_id": comment.user_id,
"parent_id": comment.parent_id,
"username": current_user.username,
"avatar": current_user.avatar,
"content": comment.content,
"created_time": comment.created_time,
}
return UnifiedResponse(success=True, data=data, message="评论成功")
@router.get("/list", response_model=UnifiedResponse)
def list_comments(
article_id: int,
current_user: Optional[User] = Depends(get_optional_user),
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""评论列表:公开文章所有人可见,好友文章仅好友/博主可见。"""
article = db.query(Article).filter(Article.id == article_id).first()
if article is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文章不存在")
if article.visibility == VISIBILITY_FRIEND and not can_read_friend_article(current_user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权查看该文章评论")
comments = (
db.query(Comment)
.filter(Comment.article_id == article_id)
.order_by(Comment.created_time.asc(), Comment.id.asc())
.all()
)
data = [
{
"id": c.id,
"article_id": c.article_id,
"user_id": c.user_id,
"parent_id": c.parent_id,
"username": c.user.username if c.user else None,
"avatar": c.user.avatar if c.user else None,
"content": c.content,
"created_time": c.created_time,
}
for c in comments
]
return UnifiedResponse(success=True, data=data, message="获取成功")
+41
View File
@@ -0,0 +1,41 @@
"""
路由公共依赖。
提供“可选登录”依赖与权限判断工具:
- 文章可见性控制需要区分“游客”和“登录用户”
- 携带有效令牌时返回用户,未携带令牌时返回 None
"""
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from ..auth import bearer_scheme, decode_token
from ..database import get_db
from ..models import ROLE_BLOGGER, ROLE_FRIEND, User
def get_optional_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: Session = Depends(get_db),
) -> Optional[User]:
"""可选登录依赖:未携带令牌返回 None;令牌无效返回 401;否则返回当前用户。"""
if credentials is None:
return None
payload = decode_token(credentials.credentials)
try:
user_id = int(payload.get("sub"))
except (TypeError, ValueError):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="令牌无效")
user = db.query(User).filter(User.id == user_id).first()
# 令牌版本校验:与 get_current_user 保持一致,密码重置后旧令牌失效
if user is not None and (payload.get("ver") or 0) != (user.token_version or 0):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录状态已失效,请重新登录")
return user
def can_read_friend_article(user: Optional[User]) -> bool:
"""判断用户是否具备查看好友文章的权限(friend / blogger)。"""
return user is not None and user.role in (ROLE_FRIEND, ROLE_BLOGGER)
+161
View File
@@ -0,0 +1,161 @@
"""
邮箱验证码路由。
接口(统一响应格式 {success, data, message}):
- POST /api/email/send-code 向邮箱发送验证码(10 分钟有效,60 秒内不可重复发送;
另有按邮箱/按 IP 的小时限流,防止被当作垃圾邮件中继)
- POST /api/email/verify-code 校验验证码(校验成功后即作废,一次性使用;
同一邮箱尝试超过 5 次自动作废验证码并要求重发)
注意:
- issue_code / verify_code 被密码重置路由复用,限流逻辑集中在两个函数内。
"""
import hmac
import os
import secrets
from datetime import timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..email import send_verification_code
from ..models import PURPOSE_REGISTER, PURPOSE_RESET, EmailCode, utcnow
from ..schemas import SendCodeRequest, UnifiedResponse, VerifyCodeRequest
from ..security import (
get_client_ip,
is_valid_email,
send_code_email_limiter,
send_code_ip_limiter,
verify_code_limiter,
)
router = APIRouter(prefix="/api/email", tags=["email"])
# 验证码有效期与同一邮箱的重发间隔(可通过 .env 调整)
CODE_TTL_MINUTES = int(os.getenv("EMAIL_CODE_TTL_MINUTES") or "10")
RESEND_INTERVAL_SECONDS = int(os.getenv("EMAIL_RESEND_INTERVAL_SECONDS") or "60")
def issue_code(email: str, purpose: str, db: Session, request: Optional[Request] = None) -> None:
"""生成验证码并发送邮件(send-code 与忘记密码复用);发送失败回滚并抛错。"""
now = utcnow()
# 限流:同一邮箱每小时最多 5 封;若带请求对象,再按 IP 每小时最多 10 封
if send_code_email_limiter.is_blocked(email):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="该邮箱发送过于频繁,请稍后再试")
if request is not None:
client_ip = get_client_ip(request)
if send_code_ip_limiter.is_blocked(client_ip):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="发送过于频繁,请稍后再试")
latest = (
db.query(EmailCode)
.filter(EmailCode.email == email, EmailCode.purpose == purpose)
.order_by(EmailCode.created_time.desc())
.first()
)
if latest is not None and latest.created_time > now - timedelta(seconds=RESEND_INTERVAL_SECONDS):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="发送过于频繁,请稍后再试")
code = f"{secrets.randbelow(1000000):06d}"
# 作废该邮箱同用途的历史验证码,只保留最新一条
db.query(EmailCode).filter(
EmailCode.email == email,
EmailCode.purpose == purpose,
).update({EmailCode.used: True})
record = EmailCode(
email=email,
code=code,
purpose=purpose,
expires_at=now + timedelta(minutes=CODE_TTL_MINUTES),
)
db.add(record)
db.flush()
try:
send_verification_code(email, code)
except Exception:
db.rollback()
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="验证码发送失败,请稍后再试")
db.commit()
# 发送成功:记录限流次数,并重置该校验尝试计数(新验证码重新计数)
send_code_email_limiter.hit(email)
if request is not None:
send_code_ip_limiter.hit(get_client_ip(request))
verify_code_limiter.reset(email)
def verify_code(payload: VerifyCodeRequest, db: Session) -> None:
"""校验验证码:正确则标记为已使用(一次性);失败过多则作废验证码。"""
email = payload.email.strip().lower()
now = utcnow()
# 尝试限流:同一邮箱尝试超过 5 次即作废当前验证码,必须重新发送
if verify_code_limiter.is_blocked(email):
db.query(EmailCode).filter(
EmailCode.email == email,
EmailCode.purpose == payload.purpose,
EmailCode.used.is_(False),
).update({EmailCode.used: True})
db.commit()
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="验证码尝试次数过多,请重新发送",
)
record = (
db.query(EmailCode)
.filter(
EmailCode.email == email,
EmailCode.purpose == payload.purpose,
EmailCode.used.is_(False),
EmailCode.expires_at > now,
)
.order_by(EmailCode.created_time.desc())
.first()
)
# 恒定时间比较,避免通过响应时间差枚举验证码
if record is None or not hmac.compare_digest(record.code, payload.code):
verify_code_limiter.hit(email)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期")
record.used = True
verify_code_limiter.reset(email)
db.commit()
@router.post("/send-code", response_model=UnifiedResponse)
def send_code(
payload: SendCodeRequest,
request: Request,
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""向指定邮箱发送验证码(带邮箱与 IP 双重限流)。"""
email = payload.email.strip().lower()
if not is_valid_email(email):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="邮箱格式不正确")
if payload.purpose not in (PURPOSE_REGISTER, PURPOSE_RESET):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用途不合法")
issue_code(email, payload.purpose, db, request)
return UnifiedResponse(
success=True,
data={"email": email, "purpose": payload.purpose},
message="验证码已发送,请查收邮件",
)
@router.post("/verify-code", response_model=UnifiedResponse)
def verify_code_route(payload: VerifyCodeRequest, db: Session = Depends(get_db)) -> UnifiedResponse:
"""校验验证码:正确则标记为已使用(一次性)。"""
email = payload.email.strip().lower()
verify_code(payload, db)
return UnifiedResponse(
success=True,
data={"email": email, "purpose": payload.purpose},
message="验证码校验通过",
)
+191
View File
@@ -0,0 +1,191 @@
"""
好友路由。
按“用户向博主申请、博主审批”实现(Friend.user_id 为申请人):
- POST /api/friend/apply 申请好友(登录用户;博主与已是好友的用户不可申请)
- GET /api/friend/status 查询当前用户的好友状态(none / pending / accepted / rejected
- GET /api/friend/applications 好友申请列表(仅博主)
- POST /api/friend/{friend_id}/approve 审批通过(仅博主):申请人角色提升为 friend
- POST /api/friend/{friend_id}/reject 审批拒绝(仅博主)
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import (
FRIEND_STATUS_ACCEPTED,
FRIEND_STATUS_PENDING,
FRIEND_STATUS_REJECTED,
ROLE_BLOGGER,
ROLE_FRIEND,
Friend,
User,
)
from ..schemas import UnifiedResponse
router = APIRouter(prefix="/api/friend", tags=["friend"])
@router.post("/apply", response_model=UnifiedResponse)
def apply_friend(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""申请好友:注册用户向博主发起好友申请。"""
if current_user.role == ROLE_BLOGGER:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="博主无需申请好友")
if current_user.role == ROLE_FRIEND:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="你们已经是好友")
record = db.query(Friend).filter(Friend.user_id == current_user.id).first()
if record is None:
# 首次申请:新建待审批记录
record = Friend(user_id=current_user.id, status=FRIEND_STATUS_PENDING)
db.add(record)
elif record.status == FRIEND_STATUS_PENDING:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="申请已提交,请等待博主审批")
elif record.status == FRIEND_STATUS_ACCEPTED:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="你们已经是好友")
else:
# 此前被拒绝:允许重新提交申请
record.status = FRIEND_STATUS_PENDING
db.commit()
db.refresh(record)
return UnifiedResponse(
success=True,
data={"id": record.id, "status": record.status},
message="申请成功,等待博主审批",
)
@router.get("/status", response_model=UnifiedResponse)
def get_friend_status(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""查询当前用户的好友状态:none / pending / accepted / rejected(博主返回 blogger)。"""
if current_user.role == ROLE_BLOGGER:
return UnifiedResponse(success=True, data={"status": "blogger"}, message="获取成功")
if current_user.role == ROLE_FRIEND:
return UnifiedResponse(success=True, data={"status": "accepted"}, message="获取成功")
record = db.query(Friend).filter(Friend.user_id == current_user.id).first()
status_value = record.status if record is not None else "none"
return UnifiedResponse(success=True, data={"status": status_value}, message="获取成功")
@router.get("/list", response_model=UnifiedResponse)
def list_friends(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""好友列表:博主查看全部已成为好友的用户;好友用户查看自己与博主的好友关系。"""
if current_user.role not in (ROLE_BLOGGER, ROLE_FRIEND):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="登录并成为好友后可查看好友列表")
if current_user.role == ROLE_BLOGGER:
# 博主视角:所有已通过的好友申请,申请人即好友
records = db.query(Friend).filter(Friend.status == FRIEND_STATUS_ACCEPTED).all()
else:
# 好友视角:自己那条已通过的申请,对应好友为博主
records = db.query(Friend).filter(
Friend.user_id == current_user.id,
Friend.status == FRIEND_STATUS_ACCEPTED,
).all()
data = []
for record in records:
member = (
db.query(User).filter(User.id == record.user_id).first()
if current_user.role == ROLE_BLOGGER
else db.query(User).filter(User.role == ROLE_BLOGGER).first()
)
if member is None:
continue
data.append({
"id": member.id,
"username": member.username,
"avatar": member.avatar,
"bio": member.bio,
"friend_since": record.created_time,
})
return UnifiedResponse(success=True, data=data, message="获取成功")
@router.get("/applications", response_model=UnifiedResponse)
def list_applications(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""好友申请列表:仅博主可查看全部申请记录。"""
if current_user.role != ROLE_BLOGGER:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以查看好友申请")
records = db.query(Friend).order_by(Friend.id.asc()).all()
data = [
{
"id": r.id,
"user_id": r.user_id,
"username": r.user.username if r.user else None,
"email": r.user.email if r.user else None,
"status": r.status,
"created_time": r.created_time,
}
for r in records
]
return UnifiedResponse(success=True, data=data, message="获取成功")
@router.post("/{friend_id}/approve", response_model=UnifiedResponse)
def approve_friend(
friend_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""审批通过好友申请:仅博主;通过后申请人角色提升为 friend。"""
if current_user.role != ROLE_BLOGGER:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以审批好友申请")
record = db.query(Friend).filter(Friend.id == friend_id).first()
if record is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="好友申请不存在")
if record.status != FRIEND_STATUS_PENDING:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该申请已处理,不能重复审批")
applicant = db.query(User).filter(User.id == record.user_id).first()
record.status = FRIEND_STATUS_ACCEPTED
if applicant is not None and applicant.role != ROLE_BLOGGER:
applicant.role = ROLE_FRIEND
db.commit()
return UnifiedResponse(
success=True,
data={"id": record.id, "status": record.status},
message="已通过好友申请",
)
@router.post("/{friend_id}/reject", response_model=UnifiedResponse)
def reject_friend(
friend_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""拒绝好友申请:仅博主。"""
if current_user.role != ROLE_BLOGGER:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以审批好友申请")
record = db.query(Friend).filter(Friend.id == friend_id).first()
if record is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="好友申请不存在")
if record.status != FRIEND_STATUS_PENDING:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该申请已处理,不能重复操作")
record.status = FRIEND_STATUS_REJECTED
db.commit()
return UnifiedResponse(
success=True,
data={"id": record.id, "status": record.status},
message="已拒绝好友申请",
)
+264
View File
@@ -0,0 +1,264 @@
# -*- coding: utf-8 -*-
"""
SSH 白名单自动更新路由(阿里云安全组)。
背景:家庭宽带公网 IP 经常变化,安全组 22 端口若只放行固定 IP,
换 IP 后就会把自己挡在门外。本路由让家庭电脑定时上报当前公网 IP,
服务器调用阿里云 ECS API 自动更新安全组 22 端口的入方向白名单。
接口(统一响应格式 {success, data, message}):
- POST /api/ipwatch/report 上报当前公网 IP,自动增删安全组 22 端口规则
- GET /api/ipwatch/status 查看服务配置状态(不含任何密钥)
安全设计:
- AccessKey 只存在于服务器 .env,家庭端只持有 IPWATCH_SECRET 上报密钥
- 上报密钥用 hmac 恒定时间比较,并按来源 IP 限流(默认 60 秒一次)
- 更新顺序“先加新规则、后删旧规则”,任何一步失败都不会让 SSH 完全断连
- 只操作“tcp 22/22 且来源为单个 IP(/32)”的规则,绝不碰 0.0.0.0/0 等其它规则
"""
import base64
import hashlib
import hmac
import ipaddress
import json
import logging
import os
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from ..schemas import UnifiedResponse
from ..security import RateLimiter, get_client_ip
router = APIRouter(prefix="/api/ipwatch", tags=["ipwatch"])
# 日志走 uvicorn 的 logger,方便 journalctl 查看
logger = logging.getLogger("uvicorn.error")
# ---------- 环境变量配置(服务器 .env,见 .env.example 中文注释) ----------
IPWATCH_SECRET = (os.getenv("IPWATCH_SECRET") or "").strip()
ALIYUN_AK_ID = (os.getenv("ALIYUN_AK_ID") or "").strip()
ALIYUN_AK_SECRET = (os.getenv("ALIYUN_AK_SECRET") or "").strip()
SECURITY_GROUP_ID = (os.getenv("IPWATCH_SECURITY_GROUP_ID") or "").strip()
IPWATCH_REGION = (os.getenv("IPWATCH_REGION") or "cn-hangzhou").strip()
IPWATCH_PORT = int(os.getenv("IPWATCH_PORT") or "22")
# 同一来源 IP 两次上报的最小间隔(秒),防止密钥泄露后被刷白名单
try:
_report_interval = int(os.getenv("IPWATCH_REPORT_INTERVAL_SECONDS") or "60")
except ValueError:
_report_interval = 60
report_limiter = RateLimiter(1, max(_report_interval, 1))
class IpWatchReport(BaseModel):
"""上报请求体:家庭端上传密钥与当前公网 IP。"""
secret: str = Field(min_length=1, max_length=256)
ip: str = Field(min_length=7, max_length=45)
def _aliyun_call(action: str, params: dict) -> dict:
"""调用阿里云 ECS RPC APIHMAC-SHA1 签名,仅用 Python 标准库)。
Aliyun OpenAPI 签名规则:对全部参数按 key 排序后拼接,
再用 AccessKeySecret 做 HMAC-SHA1,最后 BASE64 得到 Signature。
"""
query = {
"AccessKeyId": ALIYUN_AK_ID,
"Action": action,
"Format": "JSON",
"SignatureMethod": "HMAC-SHA1",
"SignatureNonce": uuid.uuid4().hex, # 每次请求唯一,防止重放
"SignatureVersion": "1.0",
"Timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"Version": "2014-05-26",
"RegionId": IPWATCH_REGION,
}
query.update(params or {})
def _enc(value: str) -> str:
"""RFC3986 百分号编码(阿里云要求保留 -_.~ 三个字符)。"""
return urllib.parse.quote(str(value), safe="-_.~")
canonical = "&".join(f"{_enc(k)}={_enc(v)}" for k, v in sorted(query.items()))
string_to_sign = "GET&%2F&" + _enc(canonical)
signature = base64.b64encode(
hmac.new((ALIYUN_AK_SECRET + "&").encode(), string_to_sign.encode(), hashlib.sha1).digest()
).decode()
url = f"https://ecs.{IPWATCH_REGION}.aliyuncs.com/?{canonical}&Signature={_enc(signature)}"
request = urllib.request.Request(url, headers={"User-Agent": "MyBlog-ipwatch/1.0"})
try:
with urllib.request.urlopen(request, timeout=20) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
# 阿里云返回错误时,响应体里是 JSON 格式的错误信息
try:
return json.loads(exc.read().decode())
except Exception:
return {"Code": "HTTP_ERROR", "Message": f"阿里云接口返回 HTTP {exc.code}"}
except Exception as exc:
return {"Code": "NETWORK_ERROR", "Message": f"无法连接阿里云接口:{exc}"}
def _fetch_ingress_rules() -> list:
"""读取安全组全部入方向规则,失败时抛出 502。"""
resp = _aliyun_call(
"DescribeSecurityGroupAttribute",
{"SecurityGroupId": SECURITY_GROUP_ID, "Direction": "ingress"},
)
if resp.get("Code"):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"读取安全组规则失败:{resp.get('Message') or resp.get('Code')}",
)
return resp.get("Permissions", {}).get("Permission", [])
def _host_ip(cidr: str) -> str:
"""把安全组来源归一化为纯 IP;非 IPv4 单 IP(/32) 返回空字符串。
阿里云返回的 SourceCidrIp 可能是 "1.2.3.4""1.2.3.4/32"
本函数只认 IPv4 的 /32 单 IP,其它(0.0.0.0/0、IPv6 等)一律忽略,
防止误删用户手动配置的放行规则。
"""
if not cidr:
return ""
value = cidr.strip()
if "/" not in value:
try:
ip = ipaddress.ip_address(value)
except ValueError:
return ""
return str(ip) if ip.version == 4 else ""
try:
network = ipaddress.ip_network(value, strict=False)
except ValueError:
return ""
if network.version != 4 or network.prefixlen != 32:
return ""
return str(network.network_address)
@router.get("/status", response_model=UnifiedResponse)
def status_info() -> UnifiedResponse:
"""查看服务配置状态(不返回任何密钥),便于部署后排查。"""
data = {
"configured": bool(IPWATCH_SECRET and ALIYUN_AK_ID and ALIYUN_AK_SECRET and SECURITY_GROUP_ID),
"region": IPWATCH_REGION,
"security_group_id": SECURITY_GROUP_ID,
"port": IPWATCH_PORT,
"report_interval_seconds": report_limiter.window_seconds,
}
return UnifiedResponse(success=True, data=data, message="获取成功")
@router.post("/report", response_model=UnifiedResponse)
def report(payload: IpWatchReport, request: Request) -> UnifiedResponse:
"""上报当前公网 IP:先加新白名单、再删旧白名单,全程不会锁死 SSH。"""
# 1. 服务配置检查(未配置时直接拒绝,避免密钥为空被绕过)
if not IPWATCH_SECRET:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="IP 白名单服务未配置")
if not (ALIYUN_AK_ID and ALIYUN_AK_SECRET):
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="阿里云密钥未配置")
if not SECURITY_GROUP_ID:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="安全组未配置")
# 2. 上报密钥校验(恒定时间比较,防止时序侧信道)
if not hmac.compare_digest(payload.secret.encode("utf-8"), IPWATCH_SECRET.encode("utf-8")):
logger.warning("ipwatch: 上报密钥错误,来源 %s", get_client_ip(request))
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="上报密钥错误")
# 3. 按来源 IP 限流,防止密钥泄露后被无限刷白名单
client_ip = get_client_ip(request)
if report_limiter.is_blocked(client_ip):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="上报过于频繁,请稍后再试")
# 4. IP 格式校验(仅支持 IPv4)
ip_text = payload.ip.strip()
try:
ip_obj = ipaddress.ip_address(ip_text)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="IP 地址格式不正确")
if ip_obj.version != 4:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 IPv4 地址")
new_ip = str(ip_obj)
new_cidr = f"{new_ip}/32"
target_range = f"{IPWATCH_PORT}/{IPWATCH_PORT}"
# 5. 读取当前 22 端口规则,判断是否需要更新
rules = _fetch_ingress_rules()
port_rules = [
r
for r in rules
if str(r.get("IpProtocol", "")).lower() == "tcp" and r.get("PortRange") == target_range
]
already_authorized = any(_host_ip(r.get("SourceCidrIp")) == new_ip for r in port_rules)
if already_authorized:
return UnifiedResponse(
success=True,
data={"changed": False, "ip": new_ip, "port": IPWATCH_PORT},
message="IP 已在白名单中,无需更新",
)
# 6. 先添加新 IP 规则(成功后才进入删除阶段,避免 SSH 断连)
add_resp = _aliyun_call(
"AuthorizeSecurityGroup",
{
"SecurityGroupId": SECURITY_GROUP_ID,
"IpProtocol": "tcp",
"PortRange": target_range,
"SourceCidrIp": new_cidr,
"Policy": "accept",
"Description": "MyBlog IPWatch 自动白名单",
},
)
if add_resp.get("Code"):
logger.error("ipwatch: 添加白名单失败 %s -> %s", new_cidr, add_resp)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"添加白名单失败:{add_resp.get('Message') or add_resp.get('Code')}",
)
# 7. 删除旧的单 IP 规则(只删 22 端口 /32 来源,绝不动 0.0.0.0/0 等规则)
removed = []
for rule in port_rules:
old_ip = _host_ip(rule.get("SourceCidrIp"))
if not old_ip or old_ip == new_ip:
continue
# 阿里云对旧规则的存储格式不统一(有的带 /32,有的不带),
# 依次尝试两种格式;任一成功即视为删除完成
revoke_ok = False
for cidr_candidate in (f"{old_ip}/32", old_ip):
revoke_resp = _aliyun_call(
"RevokeSecurityGroup",
{
"SecurityGroupId": SECURITY_GROUP_ID,
"IpProtocol": "tcp",
"PortRange": target_range,
"SourceCidrIp": cidr_candidate,
"Policy": "accept",
},
)
if not revoke_resp.get("Code"):
revoke_ok = True
break
if revoke_ok:
removed.append(old_ip)
else:
# 新规则已生效,旧规则删除失败只是残留,不影响 SSH 可用性
logger.warning("ipwatch: 删除旧规则失败 %s -> %s", old_ip, revoke_resp)
report_limiter.hit(client_ip)
data = {"changed": True, "ip": new_ip, "port": IPWATCH_PORT, "removed": removed}
logger.info("ipwatch: 白名单更新完成 新IP=%s 删除=%s", new_ip, removed)
return UnifiedResponse(success=True, data=data, message="白名单更新成功")
+85
View File
@@ -0,0 +1,85 @@
"""
点赞路由。
接口(统一响应格式 {success, data, message}):
- POST /api/like/add 点赞(好友或博主,同一用户不能重复点赞)
- GET /api/like/list 点赞列表(可见性与文章一致)
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import (
ROLE_BLOGGER,
ROLE_FRIEND,
VISIBILITY_FRIEND,
Article,
Like,
User,
)
from ..schemas import LikeCreate, UnifiedResponse
from ..security import action_limiter, get_client_ip
from .deps import can_read_friend_article, get_optional_user
router = APIRouter(prefix="/api/like", tags=["like"])
@router.post("/add", response_model=UnifiedResponse)
def add_like(
payload: LikeCreate,
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""点赞:仅好友或博主可点赞,同一用户不能重复点赞。"""
# 写操作限流(IP 维度):防止好友账号刷点赞
if action_limiter.is_blocked(get_client_ip(request)):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="操作过于频繁,请稍后再试")
if current_user.role not in (ROLE_FRIEND, ROLE_BLOGGER):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有好友或博主可以点赞")
article = db.query(Article).filter(Article.id == payload.article_id).first()
if article is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文章不存在")
exists = (
db.query(Like)
.filter(Like.article_id == payload.article_id, Like.user_id == current_user.id)
.first()
)
if exists is not None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能重复点赞")
like = Like(article_id=payload.article_id, user_id=current_user.id)
db.add(like)
db.commit()
db.refresh(like)
action_limiter.hit(get_client_ip(request))
data = {"id": like.id, "article_id": like.article_id, "user_id": like.user_id}
return UnifiedResponse(success=True, data=data, message="点赞成功")
@router.get("/list", response_model=UnifiedResponse)
def list_likes(
article_id: int,
current_user: Optional[User] = Depends(get_optional_user),
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""点赞列表:公开文章所有人可见,好友文章仅好友/博主可见。"""
article = db.query(Article).filter(Article.id == article_id).first()
if article is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文章不存在")
if article.visibility == VISIBILITY_FRIEND and not can_read_friend_article(current_user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权查看该文章点赞")
likes = db.query(Like).filter(Like.article_id == article_id).all()
# 不返回点赞用户名单(避免泄露用户名与账号关联,防枚举),
# 仅返回点赞数量与当前登录用户是否已点赞
data = {
"count": len(likes),
"includes_me": current_user is not None and any(l.user_id == current_user.id for l in likes),
}
return UnifiedResponse(success=True, data=data, message="获取成功")
+70
View File
@@ -0,0 +1,70 @@
"""
密码重置路由。
接口(统一响应格式 {success, data, message}):
- POST /api/password/forgot 向注册邮箱发送重置验证码(未注册邮箱也返回同样提示,避免泄露)
- POST /api/password/reset 校验验证码并重置密码
"""
import os
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from ..auth import hash_password
from ..database import get_db
from ..models import PURPOSE_RESET, User
from ..schemas import (
ForgotPasswordRequest,
ResetPasswordRequest,
UnifiedResponse,
VerifyCodeRequest,
)
from ..security import is_valid_email
from .email import issue_code, verify_code
router = APIRouter(prefix="/api/password", tags=["password"])
# 密码最小长度:与注册接口保持一致,可通过 .env 的 PASSWORD_MIN_LENGTH 调整(默认 6
PASSWORD_MIN_LENGTH = int(os.getenv("PASSWORD_MIN_LENGTH") or "6")
@router.post("/forgot", response_model=UnifiedResponse)
def forgot_password(
payload: ForgotPasswordRequest,
request: Request,
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""忘记密码:向已注册邮箱发送重置验证码(限流逻辑与 send-code 一致)。"""
email = payload.email.strip().lower()
if not is_valid_email(email):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="邮箱格式不正确")
user = db.query(User).filter(User.email == email).first()
if user is None:
# 未注册邮箱也返回同样提示,避免泄露邮箱是否已注册
return UnifiedResponse(success=True, data=None, message="如该邮箱已注册,验证码已发送")
issue_code(email, PURPOSE_RESET, db, request)
return UnifiedResponse(success=True, data=None, message="验证码已发送,请查收邮件")
@router.post("/reset", response_model=UnifiedResponse)
def reset_password(payload: ResetPasswordRequest, db: Session = Depends(get_db)) -> UnifiedResponse:
"""重置密码:校验验证码后更新为新密码(验证码一次性使用,且有尝试次数限制)。"""
email = payload.email.strip().lower()
if len(payload.new_password) < PASSWORD_MIN_LENGTH:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"新密码至少 {PASSWORD_MIN_LENGTH}")
if len(payload.new_password) > 128:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="新密码不能超过 128 位")
# 校验验证码(通过后验证码作废,一次性使用;尝试次数过多时抛 429)
verify_code(VerifyCodeRequest(email=email, code=payload.code, purpose=PURPOSE_RESET), db)
user = db.query(User).filter(User.email == email).first()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
user.password_hash = hash_password(payload.new_password)
# 自增令牌版本号:使该用户已签发的所有旧 JWT 立即失效(重置密码后需重新登录)
user.token_version += 1
db.commit()
return UnifiedResponse(success=True, data=None, message="密码重置成功,请使用新密码登录")
+412
View File
@@ -0,0 +1,412 @@
"""
项目路由(L0 静态托管)。
实现接口(统一响应格式 {success, data, message}):
- GET /api/project/list 项目列表(公开)
- GET /api/project/{id} 项目详情(公开)
- POST /api/project/add 添加项目(仅博主;上传 zip 自动检测纯静态并部署在线演示)
- PUT /api/project/{id} 更新项目(仅博主)
- DELETE /api/project/{id} 删除项目(仅博主)
静态托管规则(L0):
- zip 内含 index.html 且无后端/可执行文件 -> static:解压到 uploads/demos/{id}/
Nginx 通过 /demo/{id}/ 提供在线演示
- 其他情况 -> link:必须填写 GitHub 链接,"在线运行"按钮跳转到该链接
"""
import os
import re
import shutil
import tempfile
import uuid
import zipfile
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import PROJECT_ROOT, get_db
from ..models import (
PROJECT_TYPE_LINK,
PROJECT_TYPE_STATIC,
ROLE_BLOGGER,
VISIBILITY_FRIEND,
VISIBILITY_PUBLIC,
Project,
User,
)
from ..schemas import ProjectOut, UnifiedResponse
from .deps import can_read_friend_article, get_optional_user
router = APIRouter(prefix="/api/project", tags=["project"])
# 上传根目录(与 routers/upload.py 解析方式一致,可通过 .env 的 UPLOADS_DIR 覆盖)
UPLOADS_ROOT = Path(os.getenv("UPLOADS_DIR") or str(PROJECT_ROOT / "uploads"))
DEMOS_DIR = UPLOADS_ROOT / "demos"
# zip 大小上限:与项目文件上传一致(50MB)
MAX_PROJECT_SIZE = 50 * 1024 * 1024
# 危险扩展名:出现任一文件即视为"非纯静态项目",不提供在线演示(防止服务器执行代码)
DANGER_EXTS = {
".py", ".pyc", ".pyd", ".php", ".phtml", ".rb", ".pl", ".pm", ".go",
".java", ".jar", ".class", ".c", ".cpp", ".cc", ".h", ".hpp",
".sh", ".bash", ".zsh", ".bat", ".cmd", ".ps1", ".vbs",
".exe", ".dll", ".so", ".dylib", ".app", ".lua", ".asp", ".aspx",
".jsp", ".cgi", ".swift", ".rs", ".cs", ".kt", ".scala",
}
def _require_blogger(user: User) -> None:
"""校验当前用户是否为博主。"""
if user.role != ROLE_BLOGGER:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以管理项目")
def _check_visibility(visibility: str) -> None:
"""校验项目可见性取值。"""
if visibility not in (VISIBILITY_PUBLIC, VISIBILITY_FRIEND):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="visibility 只能为 public 或 friend")
def _safe_extract(zip_path: Path, dest: Path) -> None:
"""安全解压 zip:拒绝路径穿越(zip slip)与绝对路径。"""
with zipfile.ZipFile(zip_path) as zf:
dest_root = dest.resolve()
for member in zf.infolist():
raw = member.filename.replace("\\", "/")
# 拒绝绝对路径与向上穿越(..
if raw.startswith("/") or ".." in raw.split("/"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="压缩包包含非法路径")
target = (dest / raw).resolve()
if not target.is_relative_to(dest_root):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="压缩包包含非法路径")
if member.is_dir():
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(member) as src, open(target, "wb") as out:
shutil.copyfileobj(src, out)
def _find_web_root(tmp: Path) -> Optional[Path]:
"""查找静态网站根目录:zip 根目录或唯一的顶层文件夹中含 index.html。"""
if (tmp / "index.html").is_file():
return tmp
tops = [p for p in tmp.iterdir()]
if len(tops) == 1 and tops[0].is_dir() and (tops[0] / "index.html").is_file():
return tops[0]
return None
def _scan_danger(tmp: Path) -> bool:
"""扫描目录树中是否存在后端/可执行文件扩展名(不扫描解压产物之外)。"""
for p in tmp.rglob("*"):
if p.is_file() and p.suffix.lower() in DANGER_EXTS:
return True
return False
def _safe_http_url(url: str) -> bool:
"""校验链接仅允许 http/https 协议(防 javascript: 等危险协议)。"""
return bool(re.match(r"^https?://", (url or "").strip()))
def _process_zip(file: UploadFile, github_url: str) -> dict:
"""保存 zip 并检测类型。返回检测结果;失败时清理已保存文件并抛异常。"""
original = file.filename or ""
ext = original.rsplit(".", 1)[-1].lower() if "." in original else ""
if ext != "zip":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传 zip 压缩包")
content = file.file.read(MAX_PROJECT_SIZE + 1)
if len(content) > MAX_PROJECT_SIZE:
raise HTTPException(status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="项目文件超出 50MB 限制")
if content[:4] not in (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件内容不是有效的 zip")
# 保存 zip 到 uploads/project/(随机文件名)
saved_name = f"{uuid.uuid4().hex}.zip"
proj_dir = UPLOADS_ROOT / "project"
proj_dir.mkdir(parents=True, exist_ok=True)
zip_path = proj_dir / saved_name
zip_path.write_bytes(content)
download_url = f"/uploads/project/{saved_name}"
# 解压到临时目录并检测
tmpdir = Path(tempfile.mkdtemp(prefix="proj_"))
try:
_safe_extract(zip_path, tmpdir)
web_root = _find_web_root(tmpdir)
has_danger = _scan_danger(tmpdir)
except HTTPException:
zip_path.unlink(missing_ok=True)
shutil.rmtree(tmpdir, ignore_errors=True)
raise
if web_root is not None and not has_danger:
result = {
"project_type": PROJECT_TYPE_STATIC,
"demo_url": None,
"download_url": download_url,
"zip_path": zip_path,
"web_root": web_root,
"tmpdir": tmpdir,
}
elif github_url.strip():
result = {
"project_type": PROJECT_TYPE_LINK,
"demo_url": github_url.strip(),
"download_url": download_url,
"zip_path": zip_path,
"web_root": None,
"tmpdir": tmpdir,
}
else:
zip_path.unlink(missing_ok=True)
shutil.rmtree(tmpdir, ignore_errors=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该压缩包无法在线演示(缺少 index.html 或包含后端代码),请填写 GitHub 链接",
)
return result
def _deploy_static(project_id: int, web_root: Path) -> None:
"""把静态网站根目录复制到 uploads/demos/{id}/。"""
target = DEMOS_DIR / str(project_id)
if target.exists():
shutil.rmtree(target)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(web_root, target)
# copytree 会保留源临时目录的 700 权限,导致 Nginx(www-data)无法读取;
# 统一改为目录 755 / 文件 644,保证静态演示可被公开访问(顶层目录也要改)
target.chmod(0o755)
for item in target.rglob("*"):
if item.is_dir():
item.chmod(0o755)
else:
item.chmod(0o644)
def _remove_demo(project_id: int) -> None:
"""删除项目对应的在线演示目录。"""
target = DEMOS_DIR / str(project_id)
if target.exists():
shutil.rmtree(target, ignore_errors=True)
def _remove_zip(download_url: Optional[str]) -> None:
"""删除项目 zip 文件(仅限站内 uploads/project/ 路径)。"""
if not download_url:
return
parts = download_url.split("/")
if len(parts) == 4 and parts[1] == "uploads" and parts[2] == "project":
try:
(UPLOADS_ROOT / "project" / parts[3]).unlink(missing_ok=True)
except OSError:
pass
@router.get("/list", response_model=UnifiedResponse)
def list_projects(
current_user: Optional[User] = Depends(get_optional_user),
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""项目列表:公开项目所有人可见;好友项目仅好友/博主可见,游客不展示(项目无可脱敏字段)。"""
privileged = can_read_friend_article(current_user)
items = db.query(Project).order_by(Project.id.desc()).all()
visible = [p for p in items if p.visibility != VISIBILITY_FRIEND or privileged]
return UnifiedResponse(
success=True,
data=[ProjectOut.model_validate(p).model_dump() for p in visible],
message="获取成功",
)
@router.get("/{project_id}", response_model=UnifiedResponse)
def get_project(
project_id: int,
current_user: Optional[User] = Depends(get_optional_user),
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""项目详情:好友项目对非好友隐藏存在性(返回 404)。"""
project = db.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
if project.visibility == VISIBILITY_FRIEND and not can_read_friend_article(current_user):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return UnifiedResponse(
success=True,
data=ProjectOut.model_validate(project).model_dump(),
message="获取成功",
)
@router.post("/add", response_model=UnifiedResponse)
def add_project(
name: str = Form(...),
description: str = Form(""),
tech: str = Form(""),
github_url: str = Form(""),
visibility: str = Form("public"),
file: Optional[UploadFile] = File(None),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""添加项目:上传 zip 自动检测;纯静态 -> 在线演示,否则 -> GitHub 链接。支持 public / friend 可见性。"""
_require_blogger(current_user)
_check_visibility(visibility)
name = name.strip()
if not name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请填写项目名称")
if github_url.strip() and not _safe_http_url(github_url):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="GitHub 链接格式不正确")
result = None
if file is not None and file.filename:
result = _process_zip(file, github_url)
elif github_url.strip():
result = {
"project_type": PROJECT_TYPE_LINK,
"demo_url": github_url.strip(),
"download_url": None,
"zip_path": None,
"web_root": None,
"tmpdir": None,
}
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传 zip 或填写 GitHub 链接")
project = Project(
name=name,
description=description.strip() or None,
tech=tech.strip() or None,
project_type=result["project_type"],
visibility=visibility,
demo_url=result["demo_url"],
download_url=result["download_url"],
github_url=github_url.strip() or None,
)
db.add(project)
db.commit()
db.refresh(project)
try:
if result["project_type"] == PROJECT_TYPE_STATIC and result["web_root"] is not None:
_deploy_static(project.id, result["web_root"])
project.demo_url = f"/demo/{project.id}/"
db.commit()
except Exception:
# 部署失败:回滚记录并清理文件,保证数据一致
db.delete(project)
db.commit()
_remove_zip(result["download_url"])
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="在线演示部署失败")
if result["tmpdir"]:
shutil.rmtree(result["tmpdir"], ignore_errors=True)
return UnifiedResponse(
success=True,
data=ProjectOut.model_validate(project).model_dump(),
message="项目添加成功",
)
@router.put("/{project_id}", response_model=UnifiedResponse)
def update_project(
project_id: int,
name: str = Form(...),
description: str = Form(""),
tech: str = Form(""),
github_url: str = Form(""),
visibility: str = Form(""),
file: Optional[UploadFile] = File(None),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""更新项目:可修改信息或重新上传 zip(会重新检测类型并刷新在线演示)。visibility 留空表示不变。"""
_require_blogger(current_user)
if visibility:
_check_visibility(visibility)
project = db.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
name = name.strip()
if not name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请填写项目名称")
if github_url.strip() and not _safe_http_url(github_url):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="GitHub 链接格式不正确")
result = None
if file is not None and file.filename:
result = _process_zip(file, github_url)
old_type = project.project_type
old_zip = project.download_url
project.name = name
project.description = description.strip() or None
project.tech = tech.strip() or None
if visibility:
project.visibility = visibility
project.project_type = result["project_type"]
project.demo_url = result["demo_url"]
project.download_url = result["download_url"]
project.github_url = github_url.strip() or None
db.commit()
try:
if result["project_type"] == PROJECT_TYPE_STATIC and result["web_root"] is not None:
_deploy_static(project.id, result["web_root"])
project.demo_url = f"/demo/{project.id}/"
db.commit()
# 清理旧 zip 与旧演示目录
_remove_zip(old_zip)
# 原为静态、更新后不再是静态:清理旧的在线演示目录
if old_type == PROJECT_TYPE_STATIC and result["project_type"] != PROJECT_TYPE_STATIC:
_remove_demo(project.id)
except Exception:
db.rollback()
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="更新失败")
else:
# 未重新上传文件:只更新文本信息
project.name = name
project.description = description.strip() or None
project.tech = tech.strip() or None
if visibility:
project.visibility = visibility
if project.project_type == PROJECT_TYPE_LINK:
new_github = github_url.strip()
if not new_github and not project.github_url:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非静态项目必须填写 GitHub 链接")
if new_github:
project.github_url = new_github
project.demo_url = new_github
db.commit()
if result and result["tmpdir"]:
shutil.rmtree(result["tmpdir"], ignore_errors=True)
return UnifiedResponse(
success=True,
data=ProjectOut.model_validate(project).model_dump(),
message="项目已更新",
)
@router.delete("/{project_id}", response_model=UnifiedResponse)
def delete_project(
project_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""删除项目:同时清理 zip 与在线演示目录。"""
_require_blogger(current_user)
project = db.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
zip_url = project.download_url
_remove_demo(project.id)
_remove_zip(zip_url)
db.delete(project)
db.commit()
return UnifiedResponse(success=True, data=None, message="项目已删除")
+193
View File
@@ -0,0 +1,193 @@
"""
文件上传路由。
接口(统一响应格式 {success, data, message}):
- POST /api/upload/avatar 头像上传(登录用户;jpg/png/webp,最大 2MB;成功后自动保存到头像字段)
- POST /api/upload/article 文章图片上传(仅博主;jpg/png/webp,最大 6MB
- POST /api/upload/project 项目文件上传(仅博主;zip,最大 50MB)
- POST /api/upload/doc 文章文档上传(仅博主;doc/docx,默认最大 20MB;仅用于生活/学习分区)
安全规则:
- 随机文件名(uuid),不使用用户原始文件名,杜绝路径穿越
- 校验扩展名 + 文件魔数(内容真实性),防止伪装类型
- 文件仅作为静态资源由 Nginx 通过 /uploads/ 访问,禁止执行
"""
import os
import uuid
from datetime import timedelta
from pathlib import Path
from fastapi import APIRouter, Depends, Form, HTTPException, UploadFile, status
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import PROJECT_ROOT, get_db
from ..models import (
ARTICLE_CATEGORY_LIFE,
ARTICLE_CATEGORY_STUDY,
ROLE_BLOGGER,
ROLE_FRIEND,
User,
utcnow,
)
from ..schemas import UnifiedResponse
router = APIRouter(prefix="/api/upload", tags=["upload"])
# 上传根目录(可通过 .env 的 UPLOADS_DIR 覆盖;部署时 Nginx 将 /uploads/ 静态映射到该目录)
UPLOADS_ROOT = Path(os.getenv("UPLOADS_DIR") or str(PROJECT_ROOT / "uploads"))
# 允许的扩展名(值仅用于展示)
ALLOWED_IMAGES = {"jpg", "jpeg", "png", "webp"}
ALLOWED_VIDEO = {"mp4", "webm"}
ALLOWED_PROJECT = {"zip"}
ALLOWED_DOC = {"doc", "docx"}
# 大小上限(字节):头像上限可通过 .env 的 AVATAR_MAX_SIZE_MB 调整(默认 4MB
MAX_AVATAR_SIZE = int(os.getenv("AVATAR_MAX_SIZE_MB", "4")) * 1024 * 1024
# 文章图片(封面/正文插图)大小上限:可通过 .env 的 ARTICLE_IMAGE_MAX_SIZE_MB 调整(默认 6MB
MAX_IMAGE_SIZE = int(os.getenv("ARTICLE_IMAGE_MAX_SIZE_MB", "6")) * 1024 * 1024
MAX_VIDEO_SIZE = 100 * 1024 * 1024 # 文章视频 100MB
MAX_PROJECT_SIZE = 50 * 1024 * 1024 # 项目文件 50MB
# 文章文档(doc/docx)大小上限:可通过 .env 的 DOC_MAX_SIZE_MB 调整(默认 20MB
MAX_DOC_SIZE = int(os.getenv("DOC_MAX_SIZE_MB", "20")) * 1024 * 1024
def check_magic_bytes(content: bytes, ext: str) -> bool:
"""校验文件魔数,防止伪造扩展名。"""
if ext in ("jpg", "jpeg"):
return content[:3] == b"\xff\xd8\xff"
if ext == "png":
return content[:8] == b"\x89PNG\r\n\x1a\n"
if ext == "webp":
return content[:4] == b"RIFF" and content[8:12] == b"WEBP"
if ext == "mp4":
return content[4:8] == b"ftyp"
if ext == "webm":
return content[:4] == b"\x1aE\xdf\xa3"
if ext == "zip":
return content[:4] in (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08")
if ext == "doc":
# Word 97-2003OLE 复合文档魔数
return content[:8] == b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
if ext == "docx":
# Word 2007+:本质是 zip 压缩包
return content[:4] in (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08")
return False
def save_upload(file: UploadFile, subdir: str, allowed: set, max_size: int) -> dict:
"""校验并保存上传文件,返回 URL 等元信息。"""
original_name = file.filename or ""
ext = original_name.rsplit(".", 1)[-1].lower() if "." in original_name else ""
if ext not in allowed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"不支持的文件类型(允许:{' / '.join(sorted(allowed))}",
)
# 一次读入,超出上限直接拒绝(+1 用于判断是否超限)
content = file.file.read(max_size + 1)
if len(content) > max_size:
raise HTTPException(status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="文件大小超出限制")
if not check_magic_bytes(content, ext):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件内容与扩展名不符")
# 随机文件名,避免覆盖与路径穿越
saved_name = f"{uuid.uuid4().hex}.{ext}"
target_dir = UPLOADS_ROOT / subdir
target_dir.mkdir(parents=True, exist_ok=True)
(target_dir / saved_name).write_bytes(content)
return {
"url": f"/uploads/{subdir}/{saved_name}",
"filename": original_name,
"size": len(content),
}
@router.post("/avatar", response_model=UnifiedResponse)
def upload_avatar(
file: UploadFile,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""头像上传:仅好友或博主可上传(访客除注册外禁止一切上传);成功后自动更新头像字段。
更换频率限制:同一账号两次更换之间至少间隔 AVATAR_CHANGE_INTERVAL_HOURS 小时(默认 24)。
"""
if current_user.role not in (ROLE_FRIEND, ROLE_BLOGGER):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="访客不能上传头像")
# 更换冷却:距离上次更换不足 N 小时则拒绝,并提示剩余等待时间
interval_hours = int(os.getenv("AVATAR_CHANGE_INTERVAL_HOURS", "24"))
if current_user.avatar_updated_time is not None:
elapsed = utcnow() - current_user.avatar_updated_time
if elapsed < timedelta(hours=interval_hours):
remaining = timedelta(hours=interval_hours) - elapsed
hours = int(remaining.total_seconds() // 3600)
minutes = int((remaining.total_seconds() % 3600) // 60)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"头像更换过于频繁,请约 {hours} 小时 {minutes} 分钟后再试",
)
data = save_upload(file, "avatar", ALLOWED_IMAGES, MAX_AVATAR_SIZE)
current_user.avatar = data["url"]
current_user.avatar_updated_time = utcnow()
db.commit()
return UnifiedResponse(success=True, data=data, message="头像上传成功")
@router.post("/article", response_model=UnifiedResponse)
def upload_article_image(
file: UploadFile,
current_user: User = Depends(get_current_user),
) -> UnifiedResponse:
"""文章图片上传:仅博主可上传(用于封面与正文插图)。"""
if current_user.role != ROLE_BLOGGER:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以上传文章图片")
data = save_upload(file, "article", ALLOWED_IMAGES, MAX_IMAGE_SIZE)
return UnifiedResponse(success=True, data=data, message="图片上传成功")
@router.post("/video", response_model=UnifiedResponse)
def upload_article_video(
file: UploadFile,
current_user: User = Depends(get_current_user),
) -> UnifiedResponse:
"""文章视频上传:仅博主可上传(mp4 / webm,最大 100MB),供正文插入视频。"""
if current_user.role != ROLE_BLOGGER:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以上传文章视频")
data = save_upload(file, "article", ALLOWED_VIDEO, MAX_VIDEO_SIZE)
return UnifiedResponse(success=True, data=data, message="视频上传成功")
@router.post("/project", response_model=UnifiedResponse)
def upload_project_file(
file: UploadFile,
current_user: User = Depends(get_current_user),
) -> UnifiedResponse:
"""项目文件上传:仅博主可上传,支持 zip 压缩包(供下载项目使用)。"""
if current_user.role != ROLE_BLOGGER:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以上传项目文件")
data = save_upload(file, "project", ALLOWED_PROJECT, MAX_PROJECT_SIZE)
return UnifiedResponse(success=True, data=data, message="项目文件上传成功")
@router.post("/doc", response_model=UnifiedResponse)
def upload_article_doc(
file: UploadFile,
category: str = Form(...),
current_user: User = Depends(get_current_user),
) -> UnifiedResponse:
"""文章文档上传(doc / docx):仅博主可上传,且只能用于“我的生活 / 我的学习”分区的文章。"""
if current_user.role != ROLE_BLOGGER:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以上传文档")
if category not in (ARTICLE_CATEGORY_LIFE, ARTICLE_CATEGORY_STUDY):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="文档只能用于生活或学习分区的文章",
)
data = save_upload(file, "article", ALLOWED_DOC, MAX_DOC_SIZE)
return UnifiedResponse(success=True, data=data, message="文档上传成功")
+199
View File
@@ -0,0 +1,199 @@
"""
用户认证与资料路由。
实现接口(统一响应格式 {success, data, message}):
- POST /api/register 注册:邮箱 + 用户名 + 密码(≥6 位),密码 bcrypt 加密存储
- POST /api/login 登录:校验密码,成功返回 JWT 令牌与用户基础信息(带失败限流)
- GET /api/user/level 查询当前登录用户的权限级别(角色)
- GET /api/user/me 查询当前登录用户的完整资料(头像、简介)
- GET /api/user/blogger 查询博主公开资料(我的简介页面,无需登录)
- PUT /api/user/profile 更新当前用户资料(头像 / 简介)
"""
import os
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from ..auth import create_access_token, get_current_user, hash_password, verify_password
from ..database import get_db
from ..models import PURPOSE_REGISTER, ROLE_BLOGGER, ROLE_VISITOR, User
from ..schemas import (
LoginRequest,
ProfileOut,
ProfileUpdate,
TokenOut,
UnifiedResponse,
UserCreate,
UserLevelOut,
VerifyCodeRequest,
)
from ..security import (
get_client_ip,
is_valid_email,
login_failure_limiter,
login_ip_limiter,
register_ip_limiter,
)
from .email import verify_code
router = APIRouter(prefix="/api", tags=["auth"])
# 密码长度限制(可通过 .env 的 PASSWORD_MIN_LENGTH 调整,默认 6;前端同步校验)
PASSWORD_MIN_LENGTH = int(os.getenv("PASSWORD_MIN_LENGTH") or "6")
PASSWORD_MAX_LENGTH = 128
@router.post("/register", response_model=UnifiedResponse)
def register(payload: UserCreate, request: Request, db: Session = Depends(get_db)) -> UnifiedResponse:
"""注册新用户:邮箱、用户名唯一,密码以 bcrypt 哈希存储,角色固定为 visitor。
安全要求:必须携带邮箱验证码(防垃圾注册与邮箱盗用),并按 IP 限流。
"""
email = payload.email.strip().lower()
username = payload.username.strip()
password = payload.password
# 注册限流(IP 维度):同一 IP 每小时最多 3 次,防止批量注册
client_ip = get_client_ip(request)
if register_ip_limiter.is_blocked(client_ip):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="注册过于频繁,请稍后再试")
# 格式校验(与前端规则保持一致)
if not is_valid_email(email):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="邮箱格式不正确")
if len(username) < 2:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名至少 2 个字符")
if len(username) > 50:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名不能超过 50 个字符")
if len(password) < PASSWORD_MIN_LENGTH:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="密码至少 6 位")
if len(password) > PASSWORD_MAX_LENGTH:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="密码不能超过 128 位")
# 邮箱验证码校验:未提供或错误时拒绝注册(校验通过后验证码一次性作废)
if not payload.code.strip():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先获取并填写邮箱验证码")
verify_code(VerifyCodeRequest(email=email, code=payload.code, purpose=PURPOSE_REGISTER), db)
# 唯一性校验:邮箱或用户名任一冲突即返回统一提示,不区分具体是哪一项,
# 防止攻击者通过注册接口枚举已注册的邮箱 / 用户名
email_taken = db.query(User).filter(User.email == email).first() is not None
username_taken = db.query(User).filter(User.username == username).first() is not None
if email_taken or username_taken:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该邮箱或用户名已被使用,请直接登录或更换后重试",
)
# 注册用户固定为 visitor 角色,防止通过注册接口越权提升权限
user = User(
email=email,
username=username,
password_hash=hash_password(password),
role=ROLE_VISITOR,
)
db.add(user)
db.commit()
db.refresh(user)
register_ip_limiter.hit(client_ip)
data = {
"id": user.id,
"email": user.email,
"username": user.username,
"role": user.role,
}
return UnifiedResponse(success=True, data=data, message="注册成功")
@router.post("/login", response_model=UnifiedResponse)
def login(payload: LoginRequest, request: Request, db: Session = Depends(get_db)) -> UnifiedResponse:
"""登录:校验邮箱与密码,成功后返回 JWT 令牌;失败过多时按邮箱与 IP 双重限流。"""
email = payload.email.strip().lower()
client_ip = get_client_ip(request)
# IP 维度限流:同一 IP 在窗口内尝试过多直接拒绝(防分布式爆破)
if login_ip_limiter.is_blocked(client_ip):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="尝试次数过多,请稍后再试",
)
# 邮箱维度限流:同一邮箱 15 分钟内失败 5 次后直接拒绝,防止暴力破解
if login_failure_limiter.is_blocked(email):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="尝试次数过多,请 15 分钟后再试",
)
user = db.query(User).filter(User.email == email).first()
# 统一提示,避免泄露用户是否存在;失败同时记录邮箱与 IP 维度计数
if user is None or not verify_password(payload.password, user.password_hash):
login_failure_limiter.hit(email)
login_ip_limiter.hit(client_ip)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码错误")
# 登录成功:重置两类失败计数并返回令牌与用户信息
login_failure_limiter.reset(email)
login_ip_limiter.reset(client_ip)
token = create_access_token(user)
data = TokenOut(
token=token,
user_id=user.id,
username=user.username,
role=user.role,
).model_dump()
return UnifiedResponse(success=True, data=data, message="登录成功")
@router.get("/user/level", response_model=UnifiedResponse)
def get_user_level(current_user: User = Depends(get_current_user)) -> UnifiedResponse:
"""查询当前登录用户的权限级别(角色)。"""
level = UserLevelOut(user_id=current_user.id, role=current_user.role)
return UnifiedResponse(success=True, data=level.model_dump(), message="获取成功")
@router.get("/user/me", response_model=UnifiedResponse)
def get_my_profile(current_user: User = Depends(get_current_user)) -> UnifiedResponse:
"""查询当前登录用户的完整资料(含头像、简介),供个人设置使用。"""
profile = ProfileOut.model_validate(current_user)
return UnifiedResponse(success=True, data=profile.model_dump(), message="获取成功")
@router.get("/user/blogger", response_model=UnifiedResponse)
def get_blogger_profile(db: Session = Depends(get_db)) -> UnifiedResponse:
"""查询博主公开资料(我的简介页面,无需登录)。"""
blogger = db.query(User).filter(User.role == ROLE_BLOGGER).order_by(User.id.asc()).first()
if blogger is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="博主资料不存在")
data = {
"username": blogger.username,
"avatar": blogger.avatar,
"bio": blogger.bio,
"role": blogger.role,
}
return UnifiedResponse(success=True, data=data, message="获取成功")
@router.put("/user/profile", response_model=UnifiedResponse)
def update_profile(
payload: ProfileUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> UnifiedResponse:
"""更新当前用户资料:头像路径与个人简介(博主简介展示在“我的简介”页面)。"""
if payload.avatar is not None:
avatar = payload.avatar.strip() or None
# 头像仅允许站内上传路径(禁 http/https 外链,防止追踪与钓鱼图片)
if avatar and not avatar.startswith("/uploads/"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="头像地址不合法,请使用站内上传的头像")
current_user.avatar = avatar
if payload.bio is not None:
bio = payload.bio.strip()
current_user.bio = bio or None
db.commit()
db.refresh(current_user)
profile = ProfileOut.model_validate(current_user)
return UnifiedResponse(success=True, data=profile.model_dump(), message="资料已更新")
+48
View File
@@ -0,0 +1,48 @@
"""JS → WASM 预编译路由。
接口(统一响应格式 {success, data, message}):
- POST /api/project/{id}/precompile 把项目 JS 预编译为 WASM(仅博主)
- GET /api/project/{id}/wasm-report 查看最近一次编译报告(公开,仅元信息)
"""
import json
from fastapi import APIRouter, Depends, HTTPException, status
from ..auth import get_current_user
from ..models import ROLE_BLOGGER, User
from ..schemas import UnifiedResponse
from ..services import wasm_builder
router = APIRouter(prefix="/api/project", tags=["project-wasm"])
def _require_blogger(user: User) -> None:
"""校验当前用户是否为博主(与 routers/project.py 保持一致)。"""
if user.role != ROLE_BLOGGER:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以管理项目",
)
@router.post("/{project_id}/precompile", response_model=UnifiedResponse)
def precompile_project(
project_id: int,
current_user: User = Depends(get_current_user),
) -> UnifiedResponse:
"""把项目演示目录里的 .js 文件预编译为 WASM(仅博主,不执行上传代码)。"""
_require_blogger(current_user)
report = wasm_builder.compile_project(project_id)
return UnifiedResponse(success=True, data=report, message="JS 预编译完成")
@router.get("/{project_id}/wasm-report", response_model=UnifiedResponse)
def get_wasm_report(project_id: int) -> UnifiedResponse:
"""查看项目最近一次 WASM 编译报告(公开:仅文件名与大小等元信息)。"""
report_path = wasm_builder.DEMOS_DIR / str(project_id) / "wasm" / "report.json"
if not report_path.is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="该项目尚未执行 WASM 预编译",
)
report = json.loads(report_path.read_text(encoding="utf-8"))
return UnifiedResponse(success=True, data=report, message="")
+222
View File
@@ -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
+119
View File
@@ -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)
+50
View File
@@ -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()
View File
+309
View File
@@ -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()
+476
View File
@@ -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 | HTTPNginx | 0.0.0.0/0 |
| 443 | HTTPSNginx | 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.jsroot 必须指向 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
```
---
## 八、开启 HTTPSLet'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 跳转到 443certbot 自动处理)
- 记得把安全组 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 BashWindows 打开 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.confSECRET 填与服务器 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 约 300KB1MB(内含 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 <token>"
# 查看最近一次编译报告(公开接口)
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)。
+5
View File
@@ -0,0 +1,5 @@
# MyBlog 部署配置(供 update.ps1 读取,可自行修改)
# 服务器地址:SSH 登录格式 user@公网IP(必须已配置 SSH 免密登录)
SERVER=root@8.145.36.108
# 域名:用于更新后验证 HTTPS 是否正常;没有域名可留空
DOMAIN=guzhujushi.cn
+42
View File
@@ -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"
+39
View File
@@ -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 规则。
+33
View File
@@ -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"
+7
View File
@@ -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 相同的值
+77
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
# 卸载 MyBlog IP 白名单定时任务
$ErrorActionPreference = "Stop"
& schtasks /Delete /F /TN "MyBlogIPWatch" | Out-Null
if ($LASTEXITCODE -eq 0) { Write-Host "计划任务 MyBlogIPWatch 已删除" }
+49
View File
@@ -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 —— 应用启动时会在代码里
# 自动读取项目根目录 .envbackend/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
+106
View File
@@ -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 返回客户端公网 IPSSH 白名单自动更新用) ----------
# 家庭端脚本先请求此地址获取当前公网 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 块 ...
# }
+17
View File
@@ -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
+94
View File
@@ -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
+127
View File
@@ -0,0 +1,127 @@
#!/bin/bash
# ============================================================
# MyBlog 服务器端更新脚本(由本地 deploy/update.ps1 上传并执行)
# 职责:备份 -> 解压新代码 -> 数据库迁移 -> 重启服务 -> 验证
# 用法:bash /tmp/update_server.sh [域名]
# 适用:Ubuntu + systemdmyblog 服务);需要 root 或已配置免密 sudo
# 安全说明:
# - 更新前自动备份到 /root/blog-backups(含数据库与上传文件),可随时回滚
# - 只增量覆盖代码文件,不删除服务器上的任何文件(不会加 --delete)
# - 数据库迁移是幂等的:重复执行只会补齐缺失的列,不会损坏数据
# ============================================================
set -eu
# bash 支持 pipefaildash/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/"
+63
View File
@@ -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;
}
+379
View File
@@ -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 = `
<section class="about-card home-about" id="about-card"><p class="muted">加载中…</p></section>
<h2 class="section-title">最新文章</h2>
<div id="article-feed"></div>`;
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
? `<img class="about-avatar-img" src="${escapeHtml(avatar)}" alt="头像">`
: `<div class="about-avatar">${escapeHtml(name.slice(0, 1))}</div>`;
el.innerHTML = `
${avatarHtml}
<div class="about-main">
<h2>${escapeHtml(name)}</h2>
<p class="muted">${escapeHtml(ABOUT_DEFAULT.tagline)}</p>
<div class="about-bio">${bio.map((p) => `<p>${escapeHtml(p)}</p>`).join("")}</div>
<div class="social-links">
${SOCIAL_LINKS.map((s) => `<a class="social-link" href="${escapeHtml(s.url)}" target="_blank" rel="noopener"><img class="social-icon" src="${escapeHtml(s.icon)}" alt="${escapeHtml(s.name)}" loading="lazy">${escapeHtml(s.name)}</a>`).join("")}
</div>
<div class="about-actions">
${state.user && state.user.role === "blogger" ? '<button type="button" class="btn btn-outline btn-sm" id="btn-edit-bio">✏️ 编辑简介</button>' : ""}
<button type="button" class="btn btn-outline btn-sm" id="btn-donate">☕ 打赏支持</button>
</div>
<div class="tags">${(ABOUT_DEFAULT.tags || []).map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join("")}</div>
</div>`;
// 绑定按钮事件:打赏弹窗(所有人可见);编辑简介(仅博主,展示在主页简介处)
$("#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 = `
<div class="donate-modal">
<img src="${DONATE_IMAGE}" alt="打赏二维码">
<p class="donate-thanks">感谢打赏</p>
</div>`;
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 = `
<label for="bio-edit-input">个人简介</label>
<textarea id="bio-edit-input" rows="6" maxlength="2000" placeholder="介绍一下自己…(支持换行)">${escapeHtml(currentBio || "")}</textarea>
<button type="button" class="btn btn-primary btn-block" id="btn-bio-save">保存简介</button>`;
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
? `<div class="card-cover"><img src="${escapeHtml(article.cover)}" alt="${escapeHtml(article.title)}" loading="lazy">${isFriendOnly ? '<span class="cover-badge">好友专属</span>' : ""}</div>`
: `<div class="card-cover card-cover-empty">${isFriendOnly ? "🔒" : ""}</div>`;
// 分区页(compact)只展示封面 + 标题;首页展示完整元信息
const metaHtml = compact ? "" : `
<div class="card-meta">
${isFriendOnly ? '<span class="badge badge-friend">好友专属</span>' : '<span class="badge badge-public">公开</span>'}
${locked
? '<span class="muted">仅好友可见全文</span>'
: `<span>${escapeHtml(article.author_username || "")}</span><span class="muted">${formatDate(article.created_time)}</span>`}
</div>`;
card.innerHTML = `
${coverHtml}
<div class="card-body">
<h3 class="card-title">${escapeHtml(article.title)}</h3>
${metaHtml}
</div>`;
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 = '<p class="empty">暂无文章</p>';
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 = `
<h1 class="page-title">${escapeHtml(partition.name)}</h1>
<p class="muted page-sub">${sub}</p>
<div id="article-feed"></div>`;
// 分区页只展示本分区的文章卡片(封面 + 标题)
await renderArticleList($("#article-feed", app), 6, slug);
}
export async function renderProjects(app) {
document.title = "我的项目 - 孤竹居士的博客";
app.innerHTML = `
<h1 class="page-title">我的项目</h1>
<p class="muted page-sub">在线体验与源码下载</p>
<div class="project-grid"></div>`;
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 = '<p class="empty">暂无项目</p>'; 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"
? '<span class="badge badge-public">在线演示</span>'
: '<span class="badge badge-friend">GitHub 链接</span>';
const visBadge = project.visibility === "friend"
? '<span class="badge badge-friend">🔒 好友专属</span>'
: '<span class="badge badge-public">公开</span>';
const lockedIcon = project.visibility === "friend" ? " 🔒" : "";
card.innerHTML = `
<h3>${escapeHtml(project.name)}${lockedIcon}</h3>
<p class="muted">${escapeHtml(project.description || "")}</p>
<div class="tags">${(project.tech || "").split(",").map((t) => t.trim()).filter(Boolean).map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join("")}</div>
${badge}
${visBadge}
<span class="btn btn-outline btn-sm">查看详情</span>`;
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
? `<a class="btn btn-primary" href="${escapeHtml(project.demo_url)}" target="_blank" rel="noopener">🚀 在线运行</a>`
: "";
const downloadBtn = project.download_url
? `<a class="btn btn-outline" href="${escapeHtml(project.download_url)}" download>⬇ 下载项目</a>`
: "";
app.innerHTML = `
<nav class="breadcrumb">
<a data-link href="/">首页</a><span>/</span>
<a data-link href="/projects">我的项目</a><span>/</span>
<span>${escapeHtml(project.name)}</span>
</nav>
<article class="article-detail">
<h1 class="article-title">${escapeHtml(project.name)}</h1>
<p class="muted">${escapeHtml(project.description || "")}</p>
<div class="tags project-detail-tags">${(project.tech || "").split(",").map((t) => t.trim()).filter(Boolean).map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join("")}</div>
<div class="project-detail-actions">
${demoBtn}
${downloadBtn}
</div>
</article>`;
}
/* 简介已并入首页(renderHomeAbout),不再单独成页 */
export function renderNotFound(app) {
document.title = "页面不存在 - 孤竹居士的博客";
app.innerHTML = `
<div class="empty-block">
<p>404</p>
<p class="muted">页面不存在或已被移除</p>
<a class="btn btn-primary" data-link href="/">返回首页</a>
</div>`;
}
/* ---------------- 文章详情(含点赞) ---------------- */
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 = `
<nav class="breadcrumb">
<a data-link href="/">首页</a><span>/</span>
<a data-link href="/${category}">${escapeHtml(ARTICLE_CATEGORIES[category] || "分区")}</a><span>/</span>
<span>${escapeHtml(article.title)}</span>
</nav>
<article class="article-detail">
<h1 class="article-title">${escapeHtml(article.title)}</h1>
<div class="article-meta">
<span>${escapeHtml(article.author_username || "")}</span>
<span class="muted">${formatDate(article.created_time)}</span>
${isFriendOnly ? '<span class="badge badge-friend">好友专属</span>' : ""}
</div>
${article.cover ? `<div class="article-cover"><img src="${escapeHtml(article.cover)}" alt="${escapeHtml(article.title)}"></div>` : ""}
<div class="article-content">${locked ? lockedBox() : renderMarkdown(article.content)}</div>
<div id="like-bar"></div>
<section class="comments"><h2 class="section-title">评论</h2><div id="comments"></div></section>
</article>`;
if (locked) bindLockedApply();
await renderLikeBar($("#like-bar", app), articleId);
await renderComments($("#comments", app), articleId);
}
function lockedBox() {
let actionHtml = "";
if (!state.user) {
actionHtml = '<button class="btn btn-primary" id="btn-locked-apply">登录后申请好友</button>';
} else if (state.user.role === "visitor") {
if (state.friendStatus === "pending") actionHtml = '<p class="muted">申请已提交,等待博主审批</p>';
else if (state.friendStatus === "rejected") actionHtml = '<button class="btn btn-primary" id="btn-locked-apply">重新申请好友</button>';
else actionHtml = '<button class="btn btn-primary" id="btn-locked-apply">申请成为好友</button>';
} else {
actionHtml = '<p class="muted">登录好友账号后即可查看全文</p>';
}
return `
<div class="locked-box">
<p>🔒 该文章仅好友可见</p>
${actionHtml}
</div>`;
}
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 = `
<div class="like-bar">
<button class="btn btn-outline like-btn${liked ? " liked" : ""}" id="btn-like">${liked ? "👍 已点赞" : "👍 点赞"} <span class="like-count">${likes.count}</span></button>
<span class="muted">${likes.count ? `${likes.count} 人点赞` : "还没有人点赞"}</span>
</div>`;
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");
}
});
}
+210
View File
@@ -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 = `
<label for="login-email">邮箱</label>
<input id="login-email" name="email" type="email" required placeholder="you@example.com">
<label for="login-password">密码</label>
<input id="login-password" name="password" type="password" required placeholder="请输入密码">
<button class="btn btn-primary btn-block" type="submit">登录</button>
<p class="form-hint">还没有账号?<a href="#" id="goto-register">立即注册</a></p>`;
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 = `
<div class="profile-avatar-row">
<div class="profile-avatar" id="profile-avatar"><span class="muted">…</span></div>
<div>
<button type="button" class="btn btn-outline" id="btn-avatar-upload">📷 上传头像</button>
<input type="file" id="avatar-file" accept="image/jpeg,image/png,image/webp" hidden>
<p class="muted form-hint">支持 jpg / png / webp,最大 4MB</p>
</div>
</div>`;
const modal = openModal({ title: "个人设置", content });
const avatarBox = $("#profile-avatar", content);
const renderPreview = (url) => {
avatarBox.innerHTML = url
? `<img src="${escapeHtml(url)}" alt="头像预览">`
: '<span class="muted">…</span>';
};
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 = `
<label for="reg-email">邮箱</label>
<input id="reg-email" name="email" type="email" required placeholder="you@example.com">
<label for="reg-code">邮箱验证码</label>
<div class="code-row">
<input id="reg-code" name="code" type="text" inputmode="numeric" maxlength="6" placeholder="6 位验证码" required>
<button type="button" class="btn btn-outline" id="btn-send-code">发送验证码</button>
</div>
<label for="reg-username">用户名</label>
<input id="reg-username" name="username" type="text" required minlength="2" maxlength="50" placeholder="用户名(2-50 字符)">
<label for="reg-password">密码</label>
<input id="reg-password" name="password" type="password" required minlength="6" maxlength="128" placeholder="至少 6 位">
<button class="btn btn-primary btn-block" type="submit">注册</button>
<p class="form-hint">已有账号?<a href="#" id="goto-login">直接登录</a></p>`;
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 = "注册";
}
});
}
+126
View File
@@ -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 = `<p class="muted">共 ${comments.length} 条评论</p>`;
const list = document.createElement("div");
list.className = "comment-list";
if (!comments.length) list.innerHTML = '<p class="empty">暂无评论,来抢沙发吧</p>';
// 按 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 = `仅好友或博主可以评论 <button class="btn btn-outline btn-sm" id="hint-apply">申请好友</button>`;
$("#hint-apply", hint).addEventListener("click", applyFriend);
} else {
hint.innerHTML = `登录并成为好友后可以评论 <button class="btn btn-outline btn-sm" id="hint-login">去登录</button>`;
$("#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 ? `<span class="muted">回复 @${escapeHtml(parent.username || "匿名")}</span>` : "";
// 评论者头像:有头像显示图片,没有则显示空占位
const avatarHtml = comment.avatar
? `<img class="comment-avatar" src="${escapeHtml(comment.avatar)}" alt="">`
: '<span class="comment-avatar comment-avatar-empty"></span>';
item.innerHTML = `
<div class="comment-head">
<div class="comment-author">
${avatarHtml}
<strong>${escapeHtml(comment.username || "匿名")}</strong>
${parentLabel}
</div>
<span class="muted">${formatDate(comment.created_time)}</span>
</div>
<p>${escapeHtml(comment.content)}</p>`;
// 顶层评论显示“回复”按钮(仅好友/博主)
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 = `
<textarea class="comment-input" rows="2" maxlength="2000" placeholder="${escapeHtml(placeholder)}"></textarea>
<button class="btn btn-primary" type="button">${btnText}</button>`;
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;
}
+100
View File
@@ -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 = '<p class="muted">加载中…</p>';
manageModal = openModal({ title: "好友申请管理", content: body });
renderApplications(body);
}
export async function openFriendListModal() {
const body = document.createElement("div");
body.innerHTML = '<p class="muted">加载中…</p>';
const modal = openModal({ title: "好友列表", content: body });
try {
const friends = await api("/api/friend/list");
if (!friends.length) { body.innerHTML = '<p class="empty">还没有好友</p>'; return; }
body.innerHTML = friends.map((f) => `
<div class="friend-row">
${f.avatar ? `<img class="friend-avatar" src="${escapeHtml(f.avatar)}" alt="">` : '<span class="friend-avatar friend-avatar-empty"></span>'}
<div class="friend-info">
<strong>${escapeHtml(f.username)}</strong>
<span class="muted">${escapeHtml(f.bio || `成为好友于 ${formatDate(f.friend_since)}`)}</span>
</div>
</div>`).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 = '<p class="empty">暂无申请记录</p>'; 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 = `
<span class="app-user">${escapeHtml(record.username || "未知用户")}</span>
<span class="app-email muted">${escapeHtml(record.email || "")}</span>
<span class="app-time muted">${formatDate(record.created_time)}</span>
<span class="app-status">${statusText}</span>`;
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");
}
}
+1
View File
@@ -0,0 +1 @@
<svg fill="#00A1D6" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bilibili</title><path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#181717" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>

After

Width:  |  Height:  |  Size: 837 B

+1
View File
@@ -0,0 +1 @@
<svg fill="#FF0000" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>YouTube</title><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>

After

Width:  |  Height:  |  Size: 474 B

+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="孤竹居士的个人博客:生活、项目、学习与简介">
<!-- CSP 安全策略:仅允许同源脚本/样式/接口;图片允许同源、data: 与 http(s) -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: http: https:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'">
<title>孤竹居士的博客</title>
<link rel="icon" type="image/png" href="/uploads/avatar/favicon.png">
<link rel="stylesheet" href="/style.css?v=__VERSION__">
</head>
<body>
<header class="topbar">
<div class="topbar-inner">
<a class="brand" href="/" data-link>孤竹居士的博客</a>
<nav class="nav" id="nav" aria-label="主导航"></nav>
<div class="auth-area" id="auth-area"></div>
</div>
</header>
<main id="app" class="container"></main>
<!-- 自定义弹窗与通知(不使用 alert) -->
<div id="modal-root"></div>
<div id="toast-root" aria-live="polite"></div>
<script type="module" src="/main.js?v=__VERSION__"></script>
</body>
</html>
+31
View File
@@ -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 的 <a>),并收起用户下拉菜单
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();
})();
+528
View File
@@ -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 = `
<div class="empty-block">
<p>!</p>
<p class="muted">请先登录</p>
<button class="btn btn-primary" id="manage-login">去登录</button>
</div>`;
$("#manage-login").addEventListener("click", openLoginModal);
return;
}
if (state.user.role !== "blogger") {
app.innerHTML = `
<div class="empty-block">
<p>!</p>
<p class="muted">只有博主可以访问发文管理</p>
<a class="btn btn-primary" data-link href="/">返回首页</a>
</div>`;
return;
}
app.innerHTML = `
<h1 class="page-title">博主管理</h1>
<div class="manage-grid">
<section class="manage-card">
<h2 class="section-title" id="form-title">发布新文章</h2>
<form class="form" id="article-form">
<label for="art-title">标题</label>
<input id="art-title" type="text" maxlength="200" required placeholder="文章标题">
<label>封面</label>
<div class="cover-field">
<div class="cover-preview hidden" id="cover-preview">
<img id="cover-img" alt="封面预览">
<button type="button" class="btn btn-outline btn-sm" id="cover-remove">移除封面</button>
</div>
<div class="cover-actions">
<button type="button" class="btn btn-outline" id="btn-cover-upload">📷 上传封面</button>
<input type="text" id="cover-url" placeholder="或直接填写图片路径,如 /uploads/article/xxx.jpg">
</div>
<input type="file" id="cover-file" accept="image/jpeg,image/png,image/webp" hidden>
</div>
<label for="art-content">正文(Markdown</label>
<div class="editor-toolbar">
<button type="button" class="btn btn-outline btn-sm" id="btn-insert-image">🖼 插入图片</button>
<button type="button" class="btn btn-outline btn-sm" id="btn-insert-video">🎬 插入视频</button>
<button type="button" class="btn btn-outline btn-sm" id="btn-insert-doc">📄 插入文档</button>
<button type="button" class="btn btn-outline btn-sm" id="btn-preview-toggle">预览</button>
</div>
<textarea id="art-content" rows="14" placeholder="支持 Markdown:标题、列表、代码块、图片等"></textarea>
<input type="file" id="insert-image-file" accept="image/jpeg,image/png,image/webp" hidden>
<input type="file" id="insert-video-file" accept="video/mp4,video/webm" hidden>
<input type="file" id="insert-doc-file" accept=".doc,.docx" hidden>
<div class="preview-box hidden" id="preview-box">
<div class="article-content" id="preview-content"></div>
</div>
<button type="button" class="btn btn-primary btn-block" id="btn-publish">发布文章</button>
<button class="btn btn-outline btn-block hidden" id="btn-cancel-edit" type="button">取消编辑</button>
</form>
</section>
<section class="manage-card">
<h2 class="section-title">我的文章</h2>
<div id="my-articles"><p class="muted">加载中…</p></div>
</section>
<section class="manage-card manage-card-wide">
<h2 class="section-title" id="proj-form-title">项目管理(在线演示 / GitHub 链接)</h2>
<form class="form" id="project-form">
<label for="proj-name">项目名称</label>
<input id="proj-name" type="text" maxlength="100" required placeholder="项目名称">
<label for="proj-desc">项目简介</label>
<textarea id="proj-desc" rows="3" maxlength="2000" placeholder="介绍一下这个项目…"></textarea>
<label for="proj-tech">技术标签(逗号分隔)</label>
<input id="proj-tech" type="text" placeholder="如 HTML, CSS, JavaScript">
<label for="proj-github">GitHub 链接(非纯静态项目必填)</label>
<input id="proj-github" type="url" placeholder="https://github.com/你的用户名/仓库名">
<label>上传代码(zip</label>
<div class="cover-field">
<button type="button" class="btn btn-outline" id="btn-proj-upload">📦 选择 zip 文件</button>
<span class="muted" id="proj-file-name"></span>
</div>
<input type="file" id="proj-file" accept=".zip" hidden>
<p class="muted form-hint">纯 HTML/CSS/JS(含 index.html)→ 自动开通在线演示;含后端代码 → 请填写 GitHub 链接</p>
<button type="button" class="btn btn-primary btn-block" id="btn-proj-save">添加项目</button>
<button type="button" class="btn btn-outline btn-block hidden" id="btn-proj-cancel">取消编辑</button>
</form>
<h3 class="section-title proj-list-title">项目列表</h3>
<div id="proj-list"><p class="muted">加载中…</p></div>
</section>
</div>`;
// ---------- 封面 / 正文图片 / 预览 ----------
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 = `
<p class="muted">选择文章发布到的分区,以及谁可以查看。</p>
<div class="form">
<label>发布分区</label>
<div class="radio-row">
<label class="radio"><input type="radio" name="pub-category" value="life" ${editingCategory === "study" ? "" : "checked"}> 我的生活</label>
<label class="radio"><input type="radio" name="pub-category" value="study" ${editingCategory === "study" ? "checked" : ""}> 我的学习</label>
</div>
<label>可见范围</label>
<div class="radio-row">
<label class="radio"><input type="radio" name="pub-visibility" value="public" ${editingVisibility === "friend" ? "" : "checked"}> 公开(所有人可见)</label>
<label class="radio"><input type="radio" name="pub-visibility" value="friend" ${editingVisibility === "friend" ? "checked" : ""}> 好友专属(仅好友可见)</label>
</div>
</div>`;
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 = '<p class="empty">还没有发布过文章</p>'; return; }
myArticlesEl.innerHTML = "";
mine.forEach((a) => {
const row = document.createElement("div");
row.className = "my-article-row";
row.innerHTML = `
${a.cover ? `<img class="my-article-cover" src="${escapeHtml(a.cover)}" alt="">` : '<span class="my-article-cover-empty"></span>'}
<div class="my-article-info">
<a class="my-article-title" data-link href="/${a.category || "life"}/${a.id}">${escapeHtml(a.title)}</a>
<span class="muted">${formatDate(a.created_time)}</span>
</div>
${a.visibility === "friend" ? '<span class="badge badge-friend">好友专属</span>' : '<span class="badge badge-public">公开</span>'}
<div class="my-article-actions">
<button class="btn btn-outline btn-sm my-article-edit">编辑</button>
<button class="btn btn-outline btn-sm btn-danger-text my-article-delete">删除</button>
</div>`;
$(".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 = '<p class="muted">加载失败</p>';
showToast(err.message, "error");
}
}
function confirmDeleteArticle(id, title) {
const content = document.createElement("div");
content.innerHTML = `<p>确定删除文章「${escapeHtml(title)}」吗?删除后评论与点赞将一并删除,且不可恢复。</p>`;
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 = `
<p class="muted">选择谁可以查看这个项目。</p>
<div class="form">
<label>可见范围</label>
<div class="radio-row">
<label class="radio"><input type="radio" name="proj-visibility" value="public" ${editingProjVisibility === "friend" ? "" : "checked"}> 公开(所有人可见)</label>
<label class="radio"><input type="radio" name="proj-visibility" value="friend" ${editingProjVisibility === "friend" ? "checked" : ""}> 好友专属(仅好友可见)</label>
</div>
</div>`;
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 = '<p class="muted">加载失败</p>'; showToast(err.message, "error"); return; }
if (!items.length) { listEl.innerHTML = '<p class="muted">还没有项目,添加一个试试</p>'; return; }
listEl.innerHTML = "";
items.forEach((p) => {
const row = document.createElement("div");
row.className = "my-article-row";
const typeBadge = p.project_type === "static"
? '<span class="badge badge-public">在线演示</span>'
: '<span class="badge badge-friend">GitHub 链接</span>';
const visBadge = p.visibility === "friend"
? '<span class="badge badge-friend">好友专属</span>'
: '<span class="badge badge-public">公开</span>';
row.innerHTML = `
<div class="my-article-info">
<a class="my-article-title" data-link href="/projects/${p.id}">${escapeHtml(p.name)}</a>
<span class="muted">${escapeHtml(p.demo_url || "")}</span>
</div>
${visBadge}
${typeBadge}
<div class="my-article-actions">
<button class="btn btn-outline btn-sm proj-edit">编辑</button>
<button class="btn btn-outline btn-sm btn-danger-text proj-delete">删除</button>
</div>`;
$(".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 = `<p>确定删除项目「${escapeHtml(p.name)}」吗?在线演示与下载文件将一并删除。</p>`;
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();
}
+181
View File
@@ -0,0 +1,181 @@
/* =====================================================================
* Markdown 轻量渲染器(markdown.js
* ---------------------------------------------------------------------
* 【这个文件是干什么的?】
* 博客文章正文是用 Markdown 语法写的(# 标题、**加粗**、```代码块```),
* 但浏览器只认 HTML。这个文件负责把 Markdown 文本"翻译"成 HTML 字符串,
* 再交给其他模块(article.js 渲染文章正文、manage.js 做编辑预览)插入页面。
*
* 【整体思路:分两层处理】
* 第 1 层(安全):先把整篇文本用 escapeHtml 转义,
* 把 < > & 等特殊字符变成 &lt; &gt; &amp; 这样的"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
// ---- 图片:![替代文字](图片地址) -> <img> ----
// 正则逐段拆解:
// !\[([^\]]*)\] 匹配 "![" + 任意个"不是 ] 的字符"(就是替代文字,存入分组1)
// \(([^)\s]+)\) 匹配 "(" + 任意个"不是 ) 和空格 的字符"(就是 URL,存入分组2)
// /g 标志 = 全局替换,把整行里所有图片语法都处理掉
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (match, alt, url) => {
// safeUrl(url, "image"):按"图片"规则校验地址是否安全
const safe = safeUrl(url, "image");
// 安全才输出 <img>;不安全直接返回空字符串(丢弃这张图)
// loading="lazy":图片滚动到视野内才加载,省流量
return safe ? `<img src="${safe}" alt="${alt}" loading="lazy">` : "";
})
// ---- 视频:@[说明文字](视频地址) -> <video> ----
// 语法用 @[ 开头,避免与图片 ![] 混淆(发布时点"插入视频"自动生成)
.replace(/@\[([^\]]*)\]\(([^)\s]+)\)/g, (match, alt, url) => {
const safe = safeUrl(url, "image");
// 安全才输出 <video controls>(带播放控制条);不安全返回空字符串
return safe ? `<video controls preload="metadata" src="${safe}">${alt}</video>` : "";
})
// ---- 链接:[显示文字](地址) -> <a> ----
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, label, url) => {
const safe = safeUrl(url, "link");
// target="_blank":新标签页打开
// rel="noopener":禁止新页面通过 window.opener 操控本页(安全措施)
return safe ? `<a href="${safe}" target="_blank" rel="noopener">${label}</a>` : label;
})
// ---- 行内代码:`代码` -> <code> ----
// `([^`]+)` 匹配一对反引号包起来的非空内容,$1 就是里面的代码
.replace(/`([^`]+)`/g, "<code>$1</code>")
// ---- 粗体:**文字** -> <strong> ----
// \*\* 转义匹配字面的两个星号;([^*]+) 捕获"不含星号"的文字
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
// ---- 斜体:*文字* -> <em>(单个星号)----
// 注意顺序:必须先处理 **(粗体)再处理 *(斜体),
// 否则 "**粗体**" 会被单星号规则拦腰拆坏
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
// ---- 斜体(下划线写法):_文字_ -> <em> ----
.replace(/_([^_]+)_/g, "<em>$1</em>");
}
/**
* Markdown 渲染入口(块级 + 行内)
* 处理步骤:
* 1. 空内容直接返回空字符串(避免后面处理报错)
* 2. escapeHtml 转义全文(安全第一),并把 Windows 的 \r\n 统一成 \n
* 3. 用"占位符"先把 ```代码块``` 整块抽出来存进数组,
* 防止代码块里的 # 或 * 被后面的规则误当成标题 / 加粗
* 4. 逐行扫描剩余文本:标题 / 分隔线 / 引用 / 列表 / 空行 / 段落
* 5. 循环结束后把占位符换回真正的 <pre><code> 代码块
*/
export function renderMarkdown(md) {
if (!md) return "";
// 统一转义 + 统一换行符(\r\n 是 Windows 换行,\n 是 Linux/Mac 换行)
const escaped = escapeHtml(md).replace(/\r\n/g, "\n");
// 代码块缓存数组:抽出来的代码块按顺序存这里
const codeBlocks = [];
// 围栏代码块正则:```语言名\n 任意内容 ```
// ```([^\n]*) 第一个 ``` 后到换行为止 = 语言名(如 js)
// \n 换行
// ([\s\S]*?) 任意字符(\s\S 合起来表示"包括换行的任意字符")
// 后面的 ? 是非贪婪模式:遇到第一个 ``` 就停,不吞后面内容
// 回调函数里 codeBlocks 存的是拼好的 HTML,返回的是占位符
const text = escaped.replace(/```([^\n]*)\n([\s\S]*?)```/g, (match, lang, code) => {
// 去掉代码末尾多余换行,包进 <pre><code>pre 会保留代码里的空格和换行)
codeBlocks.push(`<pre><code>${code.replace(/\n$/, "")}</code></pre>`);
// 返回占位符:\u0000 是"空字符",正常文章里几乎不会出现,避免误替换
return `\u0000CODE${codeBlocks.length - 1}\u0000`;
});
let html = ""; // 最终输出的 HTML,逐行往这个字符串上追加
let list = null; // 列表缓冲:{ type: "ul"|"ol", items: [] }
// 关闭列表:把攒在缓冲里的列表项一次性输出成 <ul>/<ol>,然后清空缓冲。
// 为什么列表要"攒着"?因为列表项必须连续、外面要套同一个 <ul>
// 所以要等列表结束(遇到标题、空行等)时才知道在哪儿闭合标签。
const closeList = () => {
if (list) {
const tag = list.type === "ol" ? "ol" : "ul";
html += `<${tag}>${list.items.map((i) => `<li>${i}</li>`).join("")}</${tag}>`;
list = null;
}
};
// 逐行处理:把文本按 \n 切成一行一行的数组,依次判断每一行属于哪种块级语法
for (const rawLine of text.split("\n")) {
const line = rawLine;
// 1. 代码块占位符(形如 \u0000CODE0\u0000
// 先关闭可能开着的列表,再把占位符换成真正的代码块 HTML
const codeMatch = line.match(/^\u0000CODE(\d+)\u0000$/);
if (codeMatch) { closeList(); html += codeBlocks[Number(codeMatch[1])]; continue; }
// 2. 标题:# 一级 ## 二级 ... ###### 六级 -> <h1>~<h6>
// ^(#{1,6}):行首 1~6 个 ## 的个数 = 标题级别)
// \s+:后面至少一个空格;(.*):剩下的文字就是标题内容
const heading = line.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
closeList(); // 标题会打断列表,先收尾
const level = heading[1].length; // 捕获到的 # 字符串长度就是级别
html += `<h${level}>${inlineMarkdown(heading[2])}</h${level}>`;
continue;
}
// 3. 分隔线:整行由短横线组成(--、--- 等) -> <hr>
// ^\s*:行首可以有空格;-+:一个或多个短横线;$:一直到行尾
if (/^\s*---+$/.test(line)) { closeList(); html += "<hr>"; continue; }
// 4. 引用:> 文字 -> <blockquote>
// 为什么匹配 &gt; 而不是 >
// 因为整篇已经过 escapeHtml 转义,> 已经变成了 &gt;
const quote = line.match(/^&gt;\s?(.*)$/);
if (quote) { closeList(); html += `<blockquote>${inlineMarkdown(quote[1])}</blockquote>`; continue; }
// 5. 无序列表:- 项目 或 * 项目 或 + 项目 -> <ul><li>
// ^\s*[-*+]\s+:行首空格 + 符号(- * + 三选一)+ 空格;(.*) 是列表内容
const ulItem = line.match(/^\s*[-*+]\s+(.*)$/);
if (ulItem) {
// 当前没有列表、或是"有序"列表时,先关闭旧的,再开一个新的无序列表
if (!list || list.type !== "ul") { closeList(); list = { type: "ul", items: [] }; }
list.items.push(inlineMarkdown(ulItem[1])); // 先攒进缓冲,等列表结束统一输出
continue;
}
// 6. 有序列表:1. 项目 2. 项目 -> <ol><li>
// ^\s*\d+\.\s+:行首空格 + 数字(\d++ 点 + 空格
const olItem = line.match(/^\s*\d+\.\s+(.*)$/);
if (olItem) {
if (!list || list.type !== "ol") { closeList(); list = { type: "ol", items: [] }; }
list.items.push(inlineMarkdown(olItem[1]));
continue;
}
// 7. 空行:段落之间的分隔,同时也会打断列表
if (line.trim() === "") { closeList(); continue; }
// 8. 普通段落:剩下的所有内容都包进 <p> 段落标签
closeList();
html += `<p>${inlineMarkdown(line)}</p>`;
}
// 循环结束:如果还有没收尾的列表缓冲,补一次关闭
closeList();
return html;
}
+223
View File
@@ -0,0 +1,223 @@
/* =====================================================================
* 路由与页面壳(router.js
* ---------------------------------------------------------------------
* 【这个文件是干什么的?】
* 博客是"单页面应用"SPA):全程只有一个 index.html
* 切换页面不会刷新整个网页,而是由 JavaScript 根据网址决定显示哪块内容。
*
* 【核心概念:History API】
* - history.pushState(url, "", path):只修改地址栏网址,不刷新页面
* - window 的 popstate 事件:用户点浏览器"后退 / 前进"按钮时触发
* 两者配合,就能实现"地址变了 → 内容跟着变",而且前进后退都好用。
* 相关代码在 main.js 里:window.addEventListener("popstate", render)
*
* 【本文件的四个主要职责】
* 1. renderNav / renderAuthArea:渲染顶栏的导航链接和登录 / 用户菜单
* 2. parsePath:把网址字符串"翻译"成路由对象(/article/3 -> {name:"article", id:3}
* 3. navigate:点击链接时更新地址栏并重新渲染(SPA 的跳转核心)
* 4. render:根据当前地址分发任务,调用对应的页面渲染函数
* 其他模块(main.js / auth.js / manage.js 等)都会 import 这里的函数。
* ===================================================================== */
import { FRIEND_STATUS_TEXT, PARTITIONS, ROLE_NAMES, state } from "./state.js";
import { $, escapeHtml, showToast } from "./utils.js";
import { logout, openLoginModal, openProfileModal, openRegisterModal, refreshUser } from "./auth.js";
import { applyFriend, openFriendListModal, openFriendManageModal } from "./friend.js";
import { renderArticle, renderHome, renderNotFound, renderPartition, renderProjectDetail } from "./article.js";
import { renderManage } from "./manage.js";
/* ---------------- 导航与顶栏 ---------------- */
/** 渲染顶部导航栏
* 把导航链接拼成 HTML 字符串,塞进 <nav id="nav"> 里。
* PARTITIONS 来自 state.js,例如 [{id:1, name:"我的生活"}, ...]
* .map() 就是"数组里的每一项都生成一段链接 HTML",最后 join("") 拼成字符串。
*
* 关键属性:
* - data-link:给 main.js 的全局点击监听器用的标记,
* 点击带 data-link 的链接不会真的跳页,而是调用 navigate() 走 SPA 路由
* - data-path:记录这个链接对应的路径,供 renderNavActive 判断"当前在哪页"
*/
export function renderNav() {
const nav = $("#nav");
const home = `<a class="nav-link" data-link href="/" data-path="/">首页</a>`;
const partitionLinks = PARTITIONS.map(
(p) => `<a class="nav-link" data-link href="/${p.slug}" data-path="/${p.slug}">${escapeHtml(p.name)}</a>`
);
nav.innerHTML = home + partitionLinks.join("");
}
/** 高亮当前页面的导航链接
* 遍历导航栏里所有 .nav-link,对比它的 data-path 和当前地址 location.pathname
* 匹配的那个加上 .active 样式类(classList.toggle 第二个参数为 true 就加、false 就删)。
* 首页特殊处理:只有地址正好是 "/" 才算首页高亮。
*/
export function renderNavActive() {
const path = location.pathname;
$("#nav").querySelectorAll(".nav-link").forEach((link) => {
const target = link.dataset.path;
const active = target === "/" ? path === "/" : path.startsWith(target);
link.classList.toggle("active", active);
});
}
/** 渲染右上角登录区
* 根据全局状态 state.user 做"条件渲染"
* - 没登录:显示"登录 / 注册"两个按钮
* - 已登录:显示用户名 + 角色徽章 + 下拉菜单(菜单项按角色区分)
* visitor 游客 -> 申请好友;blogger 博主 -> 发文管理 / 好友申请管理;都有"退出登录"
*/
export function renderAuthArea() {
const area = $("#auth-area");
// 未登录:渲染两个按钮,并绑定点击事件打开登录 / 注册弹窗
if (!state.user) {
area.innerHTML = `
<button class="btn btn-text" id="btn-login">登录</button>
<button class="btn btn-primary" id="btn-register">注册</button>`;
$("#btn-login").addEventListener("click", openLoginModal);
$("#btn-register").addEventListener("click", openRegisterModal);
return;
}
// 已登录:从全局状态解构出用户名和角色
const { username, role } = state.user;
const roleName = ROLE_NAMES[role] || role; // 角色中文名,查不到就用原始值
area.innerHTML = `
<div class="user-menu">
<button class="btn btn-text" id="btn-user">👤 ${escapeHtml(username)}<span class="role-badge">${roleName}</span></button>
<div class="user-dropdown hidden" id="user-dropdown"></div>
</div>`;
const dropdown = $("#user-dropdown");
// items 数组存放"菜单项",每一项 = { label: 显示文字, action: 点击后执行的函数 }
const items = [];
if (role === "visitor") {
const label = FRIEND_STATUS_TEXT[state.friendStatus] || "申请好友";
items.push({
label,
action: state.friendStatus === "pending"
? () => showToast("申请已提交,请等待博主审批", "info") // 已申请过:只提示不再重复申请
: applyFriend,
});
}
if (role === "friend") {
items.push({ label: "个人设置", action: openProfileModal }); // 好友可上传自己的头像
items.push({ label: "好友列表", action: openFriendListModal });
}
if (role === "blogger") {
items.push({ label: "发文管理", action: () => navigate("/manage") }); // 跳转到管理页
items.push({ label: "个人设置", action: openProfileModal }); // 博主也可更换头像
items.push({ label: "好友列表", action: openFriendListModal });
items.push({ label: "好友申请管理", action: openFriendManageModal });
}
items.push({ label: "退出登录", action: logout });
// 把 items 数组渲染成下拉菜单的 <a> 列表
dropdown.innerHTML = items.map((item) => `<a class="dropdown-item" href="#">${item.label}</a>`).join("");
// 给每个菜单项绑定点击:先收起下拉,再执行对应的 action 函数
dropdown.querySelectorAll(".dropdown-item").forEach((el, index) => {
el.addEventListener("click", (e) => {
e.preventDefault(); // 阻止 <a href="#"> 的默认跳转(跳到页顶)
dropdown.classList.add("hidden");
items[index].action();
});
});
// 点用户名按钮:切换下拉菜单显示 / 隐藏
// stopPropagation 阻止事件冒泡,避免触发 main.js 里"点空白处收起菜单"的逻辑
$("#btn-user").addEventListener("click", (e) => {
e.stopPropagation();
dropdown.classList.toggle("hidden");
});
}
/** 渲染页面"外壳":导航栏 + 高亮 + 登录区
* 页面切换时,顶栏需要保持最新状态(比如登录前后、所在分区变化),
* 所以 render() 里每次都会调用这个函数刷新顶栏。
*/
export function renderShell() {
renderNav();
renderNavActive();
renderAuthArea();
}
/* ---------------- 路由(History API ---------------- */
/** 网址 -> 路由对象
* 把浏览器地址翻译成一个"路由对象",让 render 知道该渲染什么:
* "/" -> { name: "home" } 首页(简介 + 全部文章)
* "/manage" -> { name: "manage" } 博主管理页
* "/life" -> { name: "partition", slug } 生活区分区页
* "/study" -> { name: "partition", slug } 学习区分区页
* "/projects" -> { name: "partition", slug } 项目专区
* "/life/12" -> { name: "article", id } 生活区文章详情
* "/study/12" -> { name: "article", id } 学习区文章详情
* "/projects/1" -> { name: "project", id } 项目详情
* 其他任何路径 -> { name: "notfound" } 404 页
*/
export function parsePath(path) {
if (path === "/") return { name: "home" };
if (path === "/manage") return { name: "manage" };
// 分区页:/life /study /projects(不带文章 id
let match = path.match(/^\/(life|study|projects)\/?$/);
if (match) return { name: "partition", slug: match[1] };
// 文章详情:/life/1 /study/1(前缀=分区,数字=文章 id)
match = path.match(/^\/(life|study)\/(\d+)\/?$/);
if (match) return { name: "article", slug: match[1], id: Number(match[2]) };
// 项目详情:/projects/1
match = path.match(/^\/projects\/(\d+)\/?$/);
if (match) return { name: "project", id: Number(match[1]) };
return { name: "notfound" };
}
/** SPA 跳转函数
* 流程:
* 1. 如果目标路径就是当前路径,直接重新渲染(相当于"刷新"当前页)
* 2. 否则用 history.pushState 更新地址栏(不刷新页面!)
* 3. 调用 render() 根据新地址渲染对应内容
*
* 为什么不直接 location.href = path
* 那样会让浏览器整页刷新(重新下载 index.html 和所有 JS),
* SPA 的意义就是"只换内容、不重新加载"。
*/
export function navigate(path) {
if (location.pathname === path) { render(); return; }
history.pushState({}, "", path);
render();
}
/** 总渲染入口:根据当前地址决定渲染哪个页面
* 流程:
* 1. parsePath 解析当前地址 -> 得到路由对象
* 2. 先显示"加载中…"占位,避免切换页面时看到旧内容
* 3. 按路由名称调用对应的页面渲染函数(这些函数都在 article.js / manage.js 里)
* 4. 出错时显示错误页(401 登录失效不弹提示,其他错误弹 toast)
* 5. 最后把滚动条滚回顶部(新页面从顶部开始看)
*
* 注意 async/await:页面渲染函数需要向服务器请求数据(fetch),
* 所以这里是异步的——先 await 数据回来,再往 #app 里填内容。
*/
export async function render() {
const route = parsePath(location.pathname);
const app = $("#app");
app.innerHTML = '<div class="loading">加载中…</div>';
renderNavActive(); // 切换页面时同步更新导航高亮
try {
if (route.name === "home") await renderHome(app);
else if (route.name === "partition") await renderPartition(app, route.slug);
else if (route.name === "article") await renderArticle(app, route.id);
else if (route.name === "project") await renderProjectDetail(app, route.id);
else if (route.name === "manage") await renderManage(app);
else renderNotFound(app);
} catch (err) {
// 兜底错误处理:显示错误信息 + 返回首页按钮
document.title = "出错了 - 孤竹居士的博客";
app.innerHTML = `
<div class="empty-block">
<p>!</p>
<p class="muted">${escapeHtml(err.message)}</p>
<a class="btn btn-primary" data-link href="/">返回首页</a>
</div>`;
if (err.status !== 401) showToast(err.message, "error");
}
window.scrollTo(0, 0);
}
+52
View File
@@ -0,0 +1,52 @@
/* =====================================================================
* 全局常量与状态(state.js
* 各模块通过 import 共享;不包含任何 DOM 操作。
* ===================================================================== */
// 导航分区:life 生活区 / projects 项目区 / study 学习区(简介已并入首页,不再单独成区)
// 文章分区只有 life / studyprojects 是独立的项目专区
export const PARTITIONS = [
{ slug: "life", name: "我的生活" },
{ slug: "projects", name: "我的项目" },
{ slug: "study", name: "我的学习" },
];
// 文章分区中文名(用于面包屑、发布管理等展示)
export const ARTICLE_CATEGORIES = { life: "生活", study: "学习" };
// 简介默认值:实际内容优先从 /api/user/blogger 读取(博主可在管理面板修改)
export const ABOUT_DEFAULT = {
name: "孤竹居士",
tagline: "记录生活 · 折腾代码 · 终身学习",
bio: [
"你好,我是孤竹居士,欢迎来到我的个人博客。",
"这里记录我的生活点滴、项目实践与学习笔记。",
"文章分为公开与好友可见两种,欢迎申请成为好友。",
],
tags: ["Python", "Web", "Minecraft", "阅读"],
};
// 打赏二维码图片(随前端一起部署,Nginx 静态访问)
export const DONATE_IMAGE = "/icons/donate.png";
// 主页展示的社交账号链接(点击跳转对应平台主页)
export const SOCIAL_LINKS = [
{ name: "GitHub", url: "https://github.com/guzhujushi", icon: "/icons/github.svg" },
{ name: "YouTube", url: "https://www.youtube.com/@guzhujushi", icon: "/icons/youtube.svg" },
{ name: "哔哩哔哩", url: "https://b23.tv/DoV46bV", icon: "/icons/bilibili.svg" },
];
export const ROLE_NAMES = { blogger: "博主", friend: "好友", visitor: "游客" };
export const FRIEND_STATUS_TEXT = {
none: "申请好友",
pending: "等待博主审批",
accepted: "已是好友",
rejected: "重新申请好友",
blogger: "博主",
};
// 全局状态:登录用户与好友申请状态(none / pending / accepted / rejected / blogger
export const state = {
user: null, // { id, username, role }
friendStatus: "none",
};
+464
View File
@@ -0,0 +1,464 @@
/* ============ 全局变量:黑白蓝简约风 ============ */
:root {
--primary: #1a73e8;
--primary-dark: #1765cc;
--primary-soft: rgba(26, 115, 232, 0.12);
--text: #202124;
--muted: #5f6368;
--bg: #ffffff;
--surface: #f8f9fa;
--border: #dadce0;
--danger: #d93025;
--success: #188038;
--shadow: 0 1px 2px rgba(60, 64, 67, 0.2), 0 2px 6px rgba(60, 64, 67, 0.1);
--radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
line-height: 1.6;
}
a { color: var(--primary); text-decoration: none; }
img { max-width: 100%; }
.muted { color: var(--muted); }
.hidden { display: none !important; }
/* hidden 属性兜底:防止 .btn 等 display 样式覆盖 hidden 导致按钮常显(如"加载中…" */
[hidden] { display: none !important; }
.empty { color: var(--muted); text-align: center; padding: 48px 0; }
/* ============ 顶栏 ============ */
.topbar {
position: sticky;
top: 0;
z-index: 100;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border);
}
.topbar-inner {
max-width: 960px;
margin: 0 auto;
padding: 0 16px;
height: 60px;
display: flex;
align-items: center;
gap: 20px;
}
.brand {
font-size: 18px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.nav {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
overflow-x: auto;
}
.nav-link {
color: var(--muted);
padding: 8px 12px;
border-radius: 999px;
font-size: 14px;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.nav-link:hover { background: var(--surface); color: var(--text); }
.nav-link.active { color: var(--primary); background: var(--primary-soft); font-weight: 500; }
.auth-area { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
/* ============ 按钮 ============ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 9px 18px;
border-radius: 6px;
border: 1px solid transparent;
font-size: 14px;
font-family: inherit;
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s, border-color 0.15s;
text-decoration: none;
}
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover:not(:disabled) { background: var(--primary-dark); box-shadow: 0 1px 3px rgba(26, 115, 232, 0.4); }
.btn-outline { background: #fff; color: var(--primary); border-color: var(--border); }
.btn-outline:hover { background: var(--surface); }
.btn-text { background: transparent; color: var(--primary); }
.btn-text:hover { background: var(--primary-soft); }
.btn-sm { padding: 5px 12px; font-size: 13px; border-radius: 5px; }
.btn-block { width: 100%; }
/* ============ 布局 ============ */
.container { max-width: 960px; margin: 0 auto; padding: 28px 16px 64px; }
.page-title { font-size: 24px; font-weight: 600; margin-bottom: 6px; }
.page-sub { font-size: 14px; margin-bottom: 20px; }
.loading { text-align: center; color: var(--muted); padding: 80px 0; }
.empty-block { text-align: center; padding: 96px 16px; }
.empty-block p:first-child { font-size: 56px; font-weight: 700; color: var(--border); }
.empty-block .btn { margin-top: 20px; }
/* ============ 文章卡片 ============ */
.article-list { display: flex; flex-direction: column; gap: 18px; }
.article-card {
display: block;
background: #fff;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
color: var(--text);
transition: box-shadow 0.2s, transform 0.2s;
}
.article-card:hover { box-shadow: var(--shadow); transform: translateY(-2px); }
.card-cover { position: relative; max-height: 260px; overflow: hidden; background: var(--surface); }
.card-cover img { width: 100%; height: 260px; object-fit: cover; display: block; }
.card-cover-empty { height: 96px; display: flex; align-items: center; justify-content: center; font-size: 28px; color: var(--muted); }
.cover-badge {
position: absolute; top: 12px; right: 12px;
background: rgba(32, 33, 36, 0.75); color: #fff;
font-size: 12px; padding: 4px 10px; border-radius: 999px;
}
.card-body { padding: 16px 20px 18px; }
.card-title { font-size: 18px; font-weight: 600; margin-bottom: 8px; }
.card-meta { display: flex; align-items: center; gap: 12px; font-size: 13px; flex-wrap: wrap; }
/* 可见性徽章 */
.badge {
display: inline-block; font-size: 12px; padding: 2px 10px;
border-radius: 999px; border: 1px solid var(--border);
}
.badge-public { color: var(--success); background: rgba(24, 128, 56, 0.08); border-color: rgba(24, 128, 56, 0.3); }
.badge-friend { color: var(--primary); background: var(--primary-soft); border-color: rgba(26, 115, 232, 0.3); }
/* ============ 文章详情 ============ */
.article-detail .article-title { font-size: 30px; line-height: 1.3; margin-bottom: 10px; }
.article-meta { display: flex; align-items: center; gap: 14px; font-size: 13px; padding-bottom: 16px; border-bottom: 1px solid var(--border); margin-bottom: 20px; flex-wrap: wrap; }
.article-cover { margin-bottom: 20px; border-radius: var(--radius); overflow: hidden; }
.article-cover img { width: 100%; max-height: 420px; object-fit: cover; display: block; }
.article-content { font-size: 16px; line-height: 1.85; word-break: break-word; }
.article-content h1, .article-content h2, .article-content h3,
.article-content h4, .article-content h5, .article-content h6 { margin: 24px 0 12px; line-height: 1.4; }
.article-content p { margin: 12px 0; }
.article-content img { border-radius: var(--radius); margin: 12px 0; }
.article-content pre {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px 16px;
overflow-x: auto; font-size: 14px; margin: 16px 0;
}
.article-content code {
background: var(--surface); border: 1px solid var(--border);
padding: 2px 6px; border-radius: 4px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.92em;
}
.article-content pre code { background: transparent; border: none; padding: 0; font-size: 14px; }
.article-content blockquote {
border-left: 4px solid var(--border); margin: 16px 0;
padding: 4px 16px; color: var(--muted); background: var(--surface);
border-radius: 0 var(--radius) var(--radius) 0;
}
.article-content ul, .article-content ol { padding-left: 26px; margin: 12px 0; }
.article-content hr { border: none; border-top: 1px solid var(--border); margin: 24px 0; }
.article-content a { text-decoration: underline; }
/* 好友文章锁页 */
.locked-box {
text-align: center; padding: 48px 20px; margin: 24px 0;
background: var(--surface); border: 1px dashed var(--border); border-radius: var(--radius);
}
.locked-box p { margin: 8px 0; }
.locked-box .btn { margin-top: 12px; }
/* ============ 点赞 ============ */
.like-bar {
display: flex; align-items: center; gap: 14px;
padding: 16px 0; margin: 24px 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.like-btn { border-radius: 999px; }
.like-btn.liked { background: var(--primary); border-color: var(--primary); color: #fff; }
.like-count { font-weight: 600; }
/* ============ 评论 ============ */
.section-title { font-size: 18px; font-weight: 600; margin: 8px 0 14px; }
.comment-list { display: flex; flex-direction: column; gap: 12px; margin-bottom: 16px; }
.comment-item { background: var(--surface); border-radius: var(--radius); padding: 12px 16px; }
.comment-head { display: flex; justify-content: space-between; align-items: center; font-size: 13px; margin-bottom: 6px; }
.comment-form { display: flex; flex-direction: column; gap: 10px; }
.comment-form textarea { resize: vertical; }
.comment-form .btn { align-self: flex-end; }
.comment-hint { display: flex; align-items: center; gap: 10px; margin: 8px 0 24px; }
/* ============ 表单 ============ */
.form label { display: block; margin: 14px 0 6px; font-size: 13px; color: var(--muted); }
.form label:first-child { margin-top: 0; }
.form input, .form textarea {
width: 100%; padding: 10px 12px;
border: 1px solid var(--border); border-radius: var(--radius);
font-size: 14px; font-family: inherit; color: var(--text);
outline: none; background: #fff;
transition: border-color 0.15s, box-shadow 0.15s;
}
.form input:focus, .form textarea:focus { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-soft); }
.form .btn { margin-top: 20px; }
.form-hint { margin-top: 14px; font-size: 13px; color: var(--muted); text-align: center; }
/* 注册表单:验证码输入框与“发送验证码”按钮同行 */
.code-row { display: flex; gap: 8px; margin-top: 6px; }
.code-row input { flex: 1; }
.code-row .btn { white-space: nowrap; }
.form-hint a { color: var(--primary); font-weight: 500; }
/* ============ 自定义弹窗 ============ */
.modal-overlay {
position: fixed; inset: 0; z-index: 900;
background: rgba(32, 33, 36, 0.5);
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.modal {
background: #fff; border-radius: var(--radius);
width: min(420px, 100%); max-height: 86vh; overflow-y: auto;
box-shadow: 0 8px 30px rgba(60, 64, 67, 0.3);
animation: modal-in 0.18s ease-out;
}
@keyframes modal-in { from { opacity: 0; transform: translateY(12px) scale(0.98); } to { opacity: 1; transform: none; } }
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; border-bottom: 1px solid var(--border);
}
.modal-header h3 { font-size: 17px; font-weight: 600; }
.modal-close {
border: none; background: transparent; color: var(--muted);
font-size: 16px; cursor: pointer; padding: 4px 8px; border-radius: 50%;
line-height: 1;
}
.modal-close:hover { background: var(--surface); color: var(--text); }
.modal-body { padding: 20px; }
/* ============ 通知(Snackbar,替代 alert ============ */
#toast-root {
position: fixed; bottom: 28px; left: 50%; transform: translateX(-50%);
z-index: 1000; display: flex; flex-direction: column; align-items: center; gap: 8px;
pointer-events: none;
}
.toast {
background: #323232; color: #fff;
padding: 12px 22px; border-radius: 4px; font-size: 14px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.3);
max-width: 84vw; text-align: center;
opacity: 0; transform: translateY(12px);
transition: opacity 0.25s, transform 0.25s;
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast-success { background: var(--success); }
.toast-error { background: var(--danger); }
/* ============ 用户菜单 ============ */
.user-menu { position: relative; }
.role-badge {
font-size: 12px; background: var(--primary-soft); color: var(--primary);
padding: 2px 8px; border-radius: 999px; margin-left: 4px;
}
.user-dropdown {
position: absolute; right: 0; top: calc(100% + 6px); min-width: 160px;
background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow); overflow: hidden; z-index: 200;
}
.dropdown-item {
display: block; padding: 10px 16px; font-size: 14px; color: var(--text);
cursor: pointer; white-space: nowrap;
}
.dropdown-item:hover { background: var(--surface); }
/* 好友申请管理 */
.app-row {
display: flex; align-items: center; gap: 10px;
padding: 10px 0; border-bottom: 1px solid var(--border); flex-wrap: wrap;
}
.app-row:last-child { border-bottom: none; }
.app-user { font-weight: 600; }
.app-email { flex: 1; min-width: 120px; font-size: 13px; }
.app-status { font-size: 13px; }
/* ============ 项目 / 简介 ============ */
.project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 18px; }
.project-card {
border: 1px solid var(--border); border-radius: var(--radius);
padding: 20px; display: flex; flex-direction: column; gap: 10px;
transition: box-shadow 0.2s;
}
.project-card:hover { box-shadow: var(--shadow); }
.project-card h3 { font-size: 17px; }
.project-actions { display: flex; gap: 10px; margin-top: 6px; flex-wrap: wrap; }
.tags { display: flex; gap: 6px; flex-wrap: wrap; }
.tag {
font-size: 12px; color: var(--primary); background: var(--primary-soft);
padding: 2px 10px; border-radius: 999px;
}
.about-card {
display: flex; gap: 24px; align-items: flex-start;
border: 1px solid var(--border); border-radius: var(--radius);
padding: 28px; background: var(--surface);
}
.about-avatar {
width: 88px; height: 88px; flex-shrink: 0;
border-radius: 50%; background: var(--primary); color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 34px; font-weight: 600;
}
.about-card h2 { font-size: 22px; margin-bottom: 4px; }
.about-bio { margin: 14px 0; display: flex; flex-direction: column; gap: 8px; }
/* 主页简介按钮区:编辑简介(博主) + 打赏支持(所有人) */
.about-actions { display: flex; gap: 10px; flex-wrap: wrap; margin: 4px 0 14px; }
/* 打赏弹窗:二维码卡片 + 感谢文字(点击图片外任意位置关闭) */
.donate-modal {
background: #fff; border-radius: 16px; padding: 26px 30px;
display: flex; flex-direction: column; align-items: center; gap: 14px;
box-shadow: 0 8px 30px rgba(60, 64, 67, 0.3);
animation: modal-in 0.18s ease-out;
}
.donate-modal img { max-width: 260px; width: 100%; height: auto; border-radius: 12px; display: block; }
.donate-thanks { font-size: 15px; color: var(--primary); font-weight: 600; }
.proj-list-title { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
/* ============ 响应式 ============ */
@media (max-width: 720px) {
.topbar-inner { flex-wrap: wrap; height: auto; padding: 10px 12px; gap: 8px; }
.nav { order: 3; width: 100%; flex: none; }
.article-detail .article-title { font-size: 24px; }
.about-card { flex-direction: column; align-items: center; text-align: center; }
}
/* ============ 博主发文管理面板 ============ */
.manage-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
align-items: start;
margin-top: 18px;
}
.manage-card {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
background: #fff;
}
.cover-field { display: flex; flex-direction: column; gap: 10px; }
.cover-preview { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
.cover-preview img {
max-height: 200px; border-radius: var(--radius);
border: 1px solid var(--border); background: var(--surface);
}
.cover-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
#cover-url { flex: 1; min-width: 200px; }
.radio-row { display: flex; gap: 20px; margin: 6px 0 4px; }
.radio { display: inline-flex; align-items: center; gap: 6px; font-size: 14px; cursor: pointer; }
.radio input { width: auto; }
.editor-toolbar { display: flex; gap: 8px; margin: 6px 0 10px; flex-wrap: wrap; }
.preview-box {
border: 1px solid var(--border); border-radius: var(--radius);
padding: 14px 18px; margin-bottom: 12px; background: var(--surface);
max-height: 420px; overflow-y: auto;
}
.my-article-row {
display: flex; align-items: center; gap: 12px;
padding: 10px 0; border-bottom: 1px solid var(--border);
}
.my-article-row:last-child { border-bottom: none; }
.my-article-cover { width: 72px; height: 48px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
.my-article-cover-empty { width: 72px; height: 48px; border-radius: 6px; background: var(--surface); flex-shrink: 0; }
.my-article-info { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.my-article-title { font-weight: 600; color: var(--text); }
.my-article-title:hover { color: var(--primary); }
@media (max-width: 720px) {
.manage-grid { grid-template-columns: 1fr; }
}
/* ============ 评论回复 / 加载更多 / 管理面板补充样式 ============ */
.comment-reply {
margin-left: 28px;
border-left: 3px solid var(--border);
border-radius: 0 var(--radius) var(--radius) 0;
}
.comment-reply-btn { padding: 2px 8px; margin-top: 2px; }
.comment-reply-box { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--border); }
.comment-input { width: 100%; }
.load-more-btn { margin-top: 18px; }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { background: #c5221f; }
.btn-danger-text { color: var(--danger); border-color: rgba(217, 48, 37, 0.4); }
.btn-danger-text:hover { background: rgba(217, 48, 37, 0.08); }
.manage-grid-second { margin-top: 20px; }
.manage-card-wide { grid-column: 1 / -1; }
.profile-avatar-row { display: flex; align-items: center; gap: 16px; margin-bottom: 8px; }
.profile-avatar {
width: 80px; height: 80px; flex-shrink: 0;
border-radius: 50%; overflow: hidden;
background: var(--primary-soft); color: var(--muted);
display: flex; align-items: center; justify-content: center;
font-size: 14px;
}
.profile-avatar img { width: 100%; height: 100%; object-fit: cover; }
#profile-bio { width: 100%; resize: vertical; }
.about-avatar-img {
width: 88px; height: 88px; flex-shrink: 0;
border-radius: 50%; object-fit: cover;
border: 1px solid var(--border);
}
.my-article-actions { display: flex; gap: 8px; flex-shrink: 0; }
/* ============ 主页简介 / 社交链接 / 视频 / 评论头像 ============ */
.home-about { margin-bottom: 26px; }
.about-main { flex: 1; min-width: 0; }
.social-links { display: flex; gap: 10px; flex-wrap: wrap; margin: 14px 0; }
.social-link {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 14px; border: 1px solid var(--border); border-radius: 999px;
background: #fff; color: var(--text); font-size: 13px;
transition: border-color 0.2s, color 0.2s, box-shadow 0.2s;
}
.social-link:hover { border-color: var(--primary); color: var(--primary); box-shadow: var(--shadow); }
.social-link .social-icon { width: 20px; height: 20px; object-fit: contain; flex-shrink: 0; }
/* 好友列表弹窗 */
.friend-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); }
.friend-row:last-child { border-bottom: none; }
.friend-avatar { width: 44px; height: 44px; border-radius: 50%; object-fit: cover; background: var(--surface); display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
.friend-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.article-content video { max-width: 100%; border-radius: 8px; background: #000; display: block; }
.comment-author { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.comment-avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.comment-avatar-empty { background: var(--primary-soft); display: inline-block; }
.breadcrumb { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); margin-bottom: 14px; flex-wrap: wrap; }
.breadcrumb a { color: var(--primary); }
.project-detail-tags { margin: 12px 0; }
.project-detail-actions { display: flex; gap: 12px; margin-top: 18px; flex-wrap: wrap; }
.publish-title { font-size: 14px; color: var(--text); margin-bottom: 4px; }
+83
View File
@@ -0,0 +1,83 @@
/* =====================================================================
* 通用工具(utils.js
* DOM 查询 / HTML 转义 / URL 白名单 / 日期格式化 / Toast / 自定义弹窗
* ===================================================================== */
export function $(selector, root = document) {
return root.querySelector(selector);
}
export function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
// URL 协议白名单:仅允许 http/https、站内相对路径与锚点,
// 禁止 javascript: / data: / vbscript: 等危险协议,防止 XSS。
export function safeUrl(url, type = "link") {
const value = String(url ?? "").trim();
if (!value) return "";
if (/^(https?:)?\/\//i.test(value)) return value;
if (value.startsWith("/") || value.startsWith("#")) return value;
if (type === "link" && value.startsWith("mailto:")) return value;
return "";
}
export function parseDate(value) {
if (!value) return null;
let text = String(value).replace(" ", "T");
// 后端时间为无时区 UTC,补 Z 后按 UTC 解析并转为本地时区显示
if (!/[zZ]|[+-]\d{2}:\d{2}$/.test(text)) text += "Z";
const d = new Date(text);
return Number.isNaN(d.getTime()) ? null : d;
}
export function formatDate(value) {
const d = parseDate(value);
if (!d) return "";
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
/* ---------------- 通知(自定义 Snackbar,不使用 alert ---------------- */
export function showToast(message, type = "info") {
const el = document.createElement("div");
el.className = `toast toast-${type}`;
el.textContent = message;
$("#toast-root").appendChild(el);
requestAnimationFrame(() => el.classList.add("show"));
setTimeout(() => {
el.classList.remove("show");
setTimeout(() => el.remove(), 300);
}, 3200);
}
/* ---------------- 自定义弹窗 ---------------- */
export function openModal({ title, content }) {
const overlay = document.createElement("div");
overlay.className = "modal-overlay";
overlay.innerHTML = `
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-header">
<h3></h3>
<button class="modal-close" aria-label="关闭">✕</button>
</div>
<div class="modal-body"></div>
</div>`;
$(".modal h3", overlay).textContent = title;
const bodyEl = $(".modal-body", overlay);
if (typeof content === "string") bodyEl.innerHTML = content;
else bodyEl.appendChild(content);
const close = () => {
overlay.remove();
document.removeEventListener("keydown", onKey);
};
const onKey = (e) => { if (e.key === "Escape") close(); };
document.addEventListener("keydown", onKey);
$(".modal-close", overlay).addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
$("#modal-root").appendChild(overlay);
return { overlay, close };
}
BIN
View File
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
# ============================================================
# 个人博客后端 Python 依赖
# 安装:pip install -r requirements.txt
# 版本与本地开发环境保持一致(2026-08 锁定)
# ============================================================
fastapi==0.141.1
uvicorn==0.52.1
sqlalchemy==2.0.51
bcrypt==5.0.0
PyJWT==2.13.0
python-dotenv==1.2.2
python-multipart==0.0.32
pydantic==2.13.4
# 以下为可选依赖(仅本地接口测试使用):
# httpx==0.28.1