Initial commit: MyBlog full stack blog
This commit is contained in:
+193
@@ -0,0 +1,193 @@
|
||||
# ============================================================
|
||||
# MyBlog 环境变量模板
|
||||
# 用法:复制本文件为 .env,填入真实值(.env 已被 git 忽略,勿提交)
|
||||
# 说明:所有变量均可选填;不填时后端使用各变量下方的“默认值”
|
||||
# ============================================================
|
||||
|
||||
# ---------------- 基础配置 ----------------
|
||||
|
||||
# JWT 签名密钥(强烈建议必填)
|
||||
# 作用:给登录令牌签名,密钥泄露 = 可以伪造任意用户身份
|
||||
# 生成方式:python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
# 注意:后端启动时会校验,缺失则直接报错拒绝启动
|
||||
JWT_SECRET=
|
||||
|
||||
# JWT 令牌有效期(单位:分钟),默认 10080(7 天)
|
||||
# 调小(如 1440 = 1 天):登录态更快过期,更安全但需更频繁登录
|
||||
# 调大(如 43200 = 30 天):体验更好但令牌泄露风险时间更长
|
||||
JWT_EXPIRE_MINUTES=10080
|
||||
|
||||
# 数据库地址(可选),默认:backend 目录下的 blog.db(SQLite 文件)
|
||||
# 保持默认即可;如需换路径:
|
||||
# Windows 示例:sqlite:///D:/MyBlog/backend/blog.db
|
||||
# Linux 示例: sqlite:////var/www/blog/backend/blog.db
|
||||
DATABASE_URL=
|
||||
|
||||
# 上传文件根目录(可选),默认:项目根目录的 uploads/
|
||||
# 部署时务必与 Nginx 的 /uploads/ 静态映射指向同一目录
|
||||
# 相对路径按项目根目录解析
|
||||
UPLOADS_DIR=
|
||||
|
||||
# ---------------- 头像设置(可选)----------------
|
||||
|
||||
# 头像文件大小上限(MB),默认 4
|
||||
# 作用:头像上传接口拒绝超过该大小的文件(仅支持 jpg/png/webp)
|
||||
# 调大(如 8):允许更高清头像;调小(如 1):更省存储
|
||||
AVATAR_MAX_SIZE_MB=4
|
||||
|
||||
# 头像更换冷却时间(小时),默认 24
|
||||
# 作用:同一账号两次更换头像之间至少间隔该时长,防止反复更换头像
|
||||
# 调大(如 168 = 1 周):更严格;调小(如 1):更宽松
|
||||
AVATAR_CHANGE_INTERVAL_HOURS=24
|
||||
|
||||
# ---------------- 博主账号(仅初始化用)----------------
|
||||
|
||||
# 作用:执行 python -m backend.seed 时创建唯一博主账号(bcrypt 存储)
|
||||
# 账号已存在时重复执行会跳过,不会覆盖已有账号
|
||||
BLOGGER_USERNAME=孤竹居士
|
||||
BLOGGER_EMAIL=guzhujushi2008@163.com
|
||||
BLOGGER_PASSWORD=你的登录密码
|
||||
|
||||
# ---------------- SMTP 邮箱验证码 ----------------
|
||||
|
||||
# 作用:注册/忘记密码时向用户邮箱发送验证码(163 邮箱示例)
|
||||
# SMTP_PORT:465=SSL 加密(推荐);587/25=普通 STARTTLS
|
||||
# SMTP_AUTH_CODE:邮箱的“授权码”,不是登录密码(163 在设置里开启 SMTP 后获取)
|
||||
SMTP_HOST=smtp.163.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=发件邮箱地址
|
||||
SMTP_AUTH_CODE=授权码
|
||||
EMAIL_FROM=发件邮箱地址(默认与 SMTP_USER 相同)
|
||||
|
||||
# ---------------- 文章图片上传(可选)----------------
|
||||
|
||||
# 文章图片(封面 / 正文插图)大小上限(MB),默认 6
|
||||
# 作用:发文时上传封面与正文图片的接口拒绝超过该大小的文件(仅支持 jpg/png/webp)
|
||||
# 调大(如 10):允许更高清大图;调小(如 3):更省存储
|
||||
ARTICLE_IMAGE_MAX_SIZE_MB=6
|
||||
|
||||
# ---------------- 文章文档上传(可选)----------------
|
||||
|
||||
# 文章 Word 文档(doc/docx)大小上限(MB),默认 20
|
||||
# 作用:发文编辑器“插入文档”上传接口拒绝超过该大小的文件
|
||||
# 注意:文档只能用于“我的生活 / 我的学习”分区的文章(后端强制校验分区)
|
||||
# 调大(如 50):允许更大的文档;调小(如 10):更省存储
|
||||
DOC_MAX_SIZE_MB=20
|
||||
|
||||
# ---------------- 安全限流(可选,调整防护强度)----------------
|
||||
|
||||
# 登录失败次数上限,默认 5
|
||||
# 作用:同一邮箱在窗口内失败达到该次数后,即使密码正确也会被临时拒绝
|
||||
# 调小(如 3):更安全,但误输密码几次就被锁
|
||||
# 调大(如 10):更宽松,防爆破能力下降
|
||||
LOGIN_MAX_FAILURES=5
|
||||
|
||||
# 登录锁定窗口(秒),默认 900 = 15 分钟
|
||||
LOGIN_WINDOW_SECONDS=900
|
||||
|
||||
# 同一 IP 在登录窗口内最多尝试次数,默认 30
|
||||
# 作用:配合邮箱维度限流,防止攻击者换着邮箱对博主账号做分布式爆破
|
||||
# 调小(如 10):更严格,但同一 NAT 出口下的多人可能互相影响
|
||||
LOGIN_MAX_PER_IP=30
|
||||
|
||||
# 同一 IP 每小时最多注册次数,默认 3
|
||||
# 作用:注册已强制要求邮箱验证码,此处再加 IP 限流,防批量注册垃圾账号
|
||||
REGISTER_MAX_PER_IP=3
|
||||
REGISTER_WINDOW_SECONDS=3600
|
||||
|
||||
# 评论 / 点赞每分钟最多次数(按 IP),默认 30
|
||||
# 作用:防止好友账号刷评论 / 刷点赞
|
||||
ACTION_MAX_PER_MINUTE=30
|
||||
|
||||
# 同一邮箱每小时最多发送验证码次数,默认 5
|
||||
# 调小可防止被恶意刷邮件,调大可让正常用户更频繁重发
|
||||
SEND_CODE_MAX_PER_EMAIL=5
|
||||
|
||||
# 同一 IP 每小时最多发送验证码次数,默认 10
|
||||
SEND_CODE_MAX_PER_IP=10
|
||||
|
||||
# 验证码发送限流窗口(秒),默认 3600 = 1 小时
|
||||
SEND_CODE_WINDOW_SECONDS=3600
|
||||
|
||||
# 验证码最多尝试次数,默认 5
|
||||
# 作用:同一邮箱在验证码有效期内错误尝试达到该次数后,验证码自动作废,需重新发送
|
||||
# 防止 6 位验证码被暴力枚举
|
||||
VERIFY_CODE_MAX_ATTEMPTS=5
|
||||
|
||||
# 验证码尝试限流窗口(秒),默认 600 = 10 分钟
|
||||
VERIFY_CODE_WINDOW_SECONDS=600
|
||||
|
||||
# 验证码有效期(分钟),默认 10
|
||||
EMAIL_CODE_TTL_MINUTES=10
|
||||
|
||||
# 同一邮箱重发验证码的最小间隔(秒),默认 60
|
||||
EMAIL_RESEND_INTERVAL_SECONDS=60
|
||||
|
||||
# 评论最大长度(字),默认 2000
|
||||
# 同时是接口层强制上限(超出返回错误),改小可减轻存储与展示压力
|
||||
COMMENT_MAX_LENGTH=2000
|
||||
|
||||
# 注册/重置密码的最小长度(位),默认 6
|
||||
PASSWORD_MIN_LENGTH=6
|
||||
|
||||
# ---------------- SSH 白名单自动更新(可选,强烈建议)----------------
|
||||
|
||||
# 背景:家庭宽带的公网 IP 经常变化,安全组 22 端口若只放行固定 IP,
|
||||
# 换 IP 后就会把自己挡在门外。启用后,家庭电脑定时上报当前公网 IP,
|
||||
# 服务器自动调用阿里云 API 更新安全组 22 端口白名单(只放行最新 IP)。
|
||||
# 原理:家庭端 -> https://域名/myip 获取公网 IP -> 变化时调用
|
||||
# POST /api/ipwatch/report -> 服务器更新安全组(先加新规则、后删旧规则)。
|
||||
# 说明:AccessKey 只放在服务器 .env,家庭端只配置下方 IPWATCH_SECRET 即可。
|
||||
# 完整配置步骤见 deploy/DEPLOY.md「日常运维」章节。
|
||||
|
||||
# 上报密钥(必填):家庭端 ipwatch.conf 里的 SECRET 必须与此相同
|
||||
# 作用:防止任意人调用上报接口刷白名单;建议用随机字符串并定期更换
|
||||
# 生成:python3 -c "import secrets; print(secrets.token_urlsafe(24))"
|
||||
IPWATCH_SECRET=
|
||||
|
||||
# 阿里云 AccessKey(RAM 子账号,仅授予该安全组的增删查权限即可)
|
||||
# 作用:服务器调用阿里云 ECS API 更新安全组规则
|
||||
# 注意:不要用主账号密钥;建议定期在 RAM 控制台轮换
|
||||
ALIYUN_AK_ID=
|
||||
ALIYUN_AK_SECRET=
|
||||
|
||||
# 安全组 ID(必填):阿里云控制台「ECS -> 安全组 -> 基础信息」查看
|
||||
IPWATCH_SECURITY_GROUP_ID=
|
||||
|
||||
# 阿里云地域 ID(必填):乌兰察布是 cn-wulanchabu,其它地域见控制台
|
||||
IPWATCH_REGION=cn-wulanchabu
|
||||
|
||||
# 受保护的端口(默认 22 = SSH),一般无需修改
|
||||
IPWATCH_PORT=22
|
||||
|
||||
# 上报限流(秒):同一来源 IP 两次上报的最小间隔,默认 60
|
||||
# 作用:即使密钥泄露,攻击者每分钟也只能刷一条白名单
|
||||
# 调大(如 600):更安全;调小(如 10):换 IP 后恢复 SSH 更快
|
||||
IPWATCH_REPORT_INTERVAL_SECONDS=60
|
||||
|
||||
|
||||
# ---------------- JS→WASM 预编译(可选)----------------
|
||||
|
||||
# 总开关(默认 true):是否允许把上传项目的 .js 预编译为 .wasm
|
||||
# 作用:仅在服务器生成编译产物与报告,不会执行任何上传代码;
|
||||
# 浏览器演示页仍然运行原始 JS(Javy 产物面向服务端沙箱执行等场景)
|
||||
# 注意:需先在服务器执行 bash deploy/install_javy.sh 安装编译工具
|
||||
WASM_COMPILE_ENABLED=true
|
||||
|
||||
# Javy 编译工具路径(默认 /usr/local/bin/javy)
|
||||
# 安装脚本:deploy/install_javy.sh(GitHub 下载约 14MB 单文件)
|
||||
JAVY_PATH=/usr/local/bin/javy
|
||||
|
||||
# 单个 JS 文件大小上限(MB),默认 3
|
||||
# 作用:超过该大小的 JS 跳过不编译,防止超大文件撑爆内存/磁盘
|
||||
# 调大(如 10):允许编译更大的文件;调小(如 1):更保守
|
||||
WASM_MAX_JS_SIZE_MB=3
|
||||
|
||||
# 单文件编译超时(秒),默认 60
|
||||
# 作用:防止 javy 编译卡死拖慢接口;超时自动终止并记录失败
|
||||
WASM_TIMEOUT_SECONDS=60
|
||||
|
||||
# 压缩包打包成单个 wasm 时,全部 JS 的总量上限(MB),默认 10
|
||||
# 作用:防止超大项目把内存/磁盘撑爆;超过则跳过打包并在报告中注明
|
||||
# 调大(如 30):允许打包更大的项目;调小(如 3):更保守
|
||||
WASM_BUNDLE_MAX_SIZE_MB=10
|
||||
+18
@@ -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
|
||||
Generated
+12
@@ -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
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
Generated
+8
@@ -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
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+54
@@ -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>
|
||||
@@ -0,0 +1,160 @@
|
||||
# 孤竹居士的个人博客
|
||||
|
||||
部署在阿里云 Ubuntu 上的个人全栈博客系统:原生前端 SPA + FastAPI + SQLite。
|
||||
|
||||
## 技术架构
|
||||
|
||||
| 层 | 技术 |
|
||||
| --- | --- |
|
||||
| 服务器 | Ubuntu Linux + Nginx |
|
||||
| 前端 | 原生 HTML + CSS + JavaScript(SPA,History API 路由) |
|
||||
| 后端 | Python FastAPI(仅监听 127.0.0.1:8080) |
|
||||
| 数据库 | SQLite + SQLAlchemy ORM |
|
||||
| 认证 | JWT Token(bcrypt 存储密码哈希) |
|
||||
| 文件 | Nginx 静态访问 uploads 目录 |
|
||||
|
||||
- Nginx:80(HTTP)/ 443(HTTPS,待配置);`/api/*` 反代到 `127.0.0.1:8080`;`/uploads/*` 静态映射。
|
||||
- 端口:22 SSH、80 HTTP、443 HTTPS、25565 Minecraft(独立运行,不占用博客端口)。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
blog/
|
||||
├── frontend/ # 前端源码(原生 SPA,ES Modules 拆分)
|
||||
│ ├── index.html
|
||||
│ ├── style.css
|
||||
│ ├── main.js # 入口:初始化 + 全局事件
|
||||
│ ├── api.js # 网络层:api() / uploadFile()
|
||||
│ ├── auth.js # 登录 / 注册 / 会话
|
||||
│ ├── router.js # 路由与页面壳(导航栏、用户区)
|
||||
│ ├── article.js # 文章列表 / 详情 / 分区 / 简介视图
|
||||
│ ├── comment.js # 评论与回复
|
||||
│ ├── friend.js # 好友申请
|
||||
│ ├── manage.js # 博主管理面板(发文 / 编辑 / 简介设置)
|
||||
│ ├── markdown.js # Markdown 轻量渲染(含 URL 白名单)
|
||||
│ ├── state.js # 全局常量与状态
|
||||
│ └── utils.js # DOM / 日期 / 弹窗 / Toast 工具
|
||||
├── backend/
|
||||
│ ├── main.py # FastAPI 实例 + 路由注册 + 统一错误格式
|
||||
│ ├── database.py # SQLite 连接与初始化
|
||||
│ ├── models.py # ORM 模型(User/Article/Comment/Like/Friend/EmailCode)
|
||||
│ ├── schemas.py # Pydantic 请求/响应模型
|
||||
│ ├── auth.py # bcrypt + JWT + 当前用户依赖
|
||||
│ ├── security.py # 邮箱校验、IP 提取、内存限流器
|
||||
│ ├── email.py # SMTP 验证码发送
|
||||
│ ├── seed.py # 博主账号初始化(python -m backend.seed)
|
||||
│ └── routers/ # 业务路由(user/article/comment/like/friend/email/password/upload)
|
||||
├── uploads/
|
||||
│ ├── avatar/ # 头像
|
||||
│ ├── article/ # 文章图片(封面/插图)
|
||||
│ └── project/ # 项目文件(zip)
|
||||
├── .env # 环境配置(已被 .gitignore 忽略,勿提交)
|
||||
├── .env.example # 环境变量模板(全中文注释)
|
||||
├── requirements.txt # Python 依赖
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 快速开始(本地开发)
|
||||
|
||||
1. 安装依赖:`pip install -r requirements.txt`
|
||||
2. 配置环境:复制 `.env.example` 为 `.env`,填写 `JWT_SECRET`、`BLOGGER_*`、`SMTP_*`。
|
||||
3. 初始化数据库与博主:`python -m backend.seed`
|
||||
4. 启动后端:`uvicorn backend.main:app --host 127.0.0.1 --port 8080`
|
||||
5. 启动前端:任意静态服务器指向 `frontend/`,并将 `/api/*` 反代到 `127.0.0.1:8080`、`/uploads/*` 映射到 `uploads/`(本地开发可借助 Nginx 或简单代理脚本实现)。
|
||||
|
||||
> 提示:前端为 SPA,任意路径(如 `/article/1`)都应回退到 `index.html`。
|
||||
|
||||
## 权限与角色
|
||||
|
||||
| 角色 | 权限 |
|
||||
| --- | --- |
|
||||
| visitor(游客) | 查看公开文章、申请好友(好友文章仅显示标题与封面) |
|
||||
| friend(好友) | 查看公开与好友文章、评论(含回复)、点赞 |
|
||||
| blogger(博主) | 全部权限:发布/编辑/删除文章、审批好友申请、上传图片与项目、修改简介与头像 |
|
||||
|
||||
## API 一览(统一前缀 `/api`,统一响应 `{success, data, message}`)
|
||||
|
||||
**认证与资料**
|
||||
- `POST /api/register` 注册(邮箱/用户名/密码,bcrypt 存储)
|
||||
- `POST /api/login` 登录(JWT;失败限流)
|
||||
- `GET /api/user/level` 当前角色
|
||||
- `GET /api/user/me` 当前用户资料
|
||||
- `GET /api/user/blogger` 博主公开资料(简介页)
|
||||
- `PUT /api/user/profile` 更新头像/简介
|
||||
|
||||
**文章**
|
||||
- `POST /api/article/add` 发布(仅博主)
|
||||
- `GET /api/article/list?page=&page_size=` 列表(分页;好友文章对游客仅标题+封面)
|
||||
- `GET /api/article/{id}` 详情(好友文章对游客锁定正文)
|
||||
- `PUT /api/article/{id}` 编辑(仅博主)
|
||||
- `DELETE /api/article/{id}` 删除(仅博主,级联评论/点赞)
|
||||
|
||||
**评论 / 点赞 / 好友**
|
||||
- `POST /api/comment/add` 评论或回复(`parent_id` 可选;仅好友/博主)
|
||||
- `GET /api/comment/list?article_id=` 评论列表
|
||||
- `POST /api/like/add` 点赞(好友/博主,不可重复)
|
||||
- `GET /api/like/list?article_id=` 点赞列表
|
||||
- `POST /api/friend/apply` 申请好友 / `GET /api/friend/status` 好友状态
|
||||
- `GET /api/friend/applications` 申请列表(仅博主)
|
||||
- `POST /api/friend/{id}/approve|reject` 审批(仅博主)
|
||||
|
||||
**邮箱验证码 / 密码**
|
||||
- `POST /api/email/send-code` 发送验证码(邮箱+IP 限流)
|
||||
- `POST /api/email/verify-code` 校验验证码(尝试次数限制)
|
||||
- `POST /api/password/forgot` 忘记密码 / `POST /api/password/reset` 重置密码
|
||||
|
||||
**上传**
|
||||
- `POST /api/upload/avatar` 头像(jpg/png/webp,≤2MB)
|
||||
- `POST /api/upload/article` 文章图片(仅博主,≤5MB)
|
||||
- `POST /api/upload/project` 项目文件(仅博主,zip,≤50MB)
|
||||
|
||||
## 安全说明
|
||||
|
||||
- 密码 bcrypt 哈希存储,禁止明文;localStorage 仅保存 token 与用户名。
|
||||
- JWT_SECRET 缺失时后端拒绝启动(fail-fast),禁止硬编码弱密钥。
|
||||
- 登录、验证码发送/校验均有内存限流(阈值可在 `.env` 调整)。
|
||||
- 上传文件:白名单扩展名 + 魔数校验 + 随机文件名,禁止执行用户上传内容。
|
||||
- 前端 Markdown 渲染带 URL 协议白名单(`javascript:`/`data:` 等被拦截)+ CSP 响应头/标签(纵深防御)。
|
||||
- 后端仅监听 127.0.0.1:8080,禁止公网直连。
|
||||
- 注册接口对“邮箱 / 用户名已占用”返回统一提示,且先核验验证码再查唯一性(防枚举);注册失败会作废本次验证码,需重新发送后再试。
|
||||
|
||||
## 部署(阿里云)
|
||||
|
||||
> 详细的 Nginx 站点配置与 systemd 服务文件将在后续任务中补充(当前仅给出要点)。
|
||||
|
||||
1. 上传项目到 `/var/www/blog`,安装依赖:`pip install -r requirements.txt`。
|
||||
2. 配置 `.env`(强随机 `JWT_SECRET`、博主账号、SMTP、安全限流)。
|
||||
3. `python -m backend.seed` 初始化数据库与博主。
|
||||
4. Nginx:80 端口托管 `frontend/` 静态文件;`/api/*` 反代 `127.0.0.1:8080`;`/uploads/*` 映射 `uploads/`;SPA 回退 `index.html`。
|
||||
5. systemd 守护 `uvicorn`(开机自启、崩溃重启)。
|
||||
6. 安全组仅开放 22 / 80 / 443 / 25565。
|
||||
|
||||
## 数据库升级说明(仅旧库需要)
|
||||
|
||||
全新部署无需迁移:`python -m backend.seed` 会自动按最新结构建表。
|
||||
|
||||
若从旧版本升级(服务器上已有 `backend/blog.db`),需先补齐新字段再重启服务:
|
||||
|
||||
```bash
|
||||
# 服务器(Ubuntu)执行;正式升级前建议先备份 backend/blog.db 与 uploads/
|
||||
cd /var/www/blog
|
||||
python3 - <<'EOF'
|
||||
import sqlite3, datetime
|
||||
con = sqlite3.connect("backend/blog.db")
|
||||
con.execute("ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0")
|
||||
con.execute("ALTER TABLE friends ADD COLUMN created_time DATETIME")
|
||||
con.execute("ALTER TABLE articles ADD COLUMN category VARCHAR(30) NOT NULL DEFAULT 'life'")
|
||||
con.execute("ALTER TABLE users ADD COLUMN avatar_updated_time DATETIME")
|
||||
now = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) # 与后端 utcnow() 一致:无时区 UTC
|
||||
con.execute("UPDATE friends SET created_time=? WHERE created_time IS NULL", (now,))
|
||||
con.commit()
|
||||
con.close()
|
||||
print("数据库升级完成")
|
||||
EOF
|
||||
```
|
||||
|
||||
本地开发(Windows)若仅为调试数据,可省略迁移:删除 `backend/blog.db` 后重新执行 `python -m backend.seed` 即可。
|
||||
|
||||
字段说明:
|
||||
- `users.token_version`:JWT 令牌版本号。重置密码后版本自增,该用户所有旧令牌立即失效(登录状态吊销)。
|
||||
- `friends.created_time`:好友申请创建时间,用于博主审批列表展示申请时间。
|
||||
@@ -0,0 +1 @@
|
||||
"""MyBlog 后端包。"""
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
用户认证模块。
|
||||
|
||||
职责:
|
||||
- bcrypt 密码哈希与校验(禁止明文保存密码)
|
||||
- JWT 令牌的生成与解析(载荷包含用户 id、角色、过期时间)
|
||||
- FastAPI 依赖 get_current_user:从请求头解析当前登录用户
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .database import PROJECT_ROOT, get_db
|
||||
from .models import User
|
||||
|
||||
# 加载项目根目录下的 .env,确保 JWT_SECRET 可用(重复调用无副作用)
|
||||
load_dotenv(PROJECT_ROOT / ".env")
|
||||
|
||||
# JWT 签名密钥:必须在 .env 中配置。
|
||||
# 缺失时直接报错(fail-fast),禁止回退到硬编码默认密钥,避免生产环境使用弱密钥。
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "").strip()
|
||||
if not JWT_SECRET:
|
||||
raise RuntimeError(
|
||||
"缺少 JWT_SECRET 环境变量:请在项目根目录 .env 中配置强随机密钥后重试"
|
||||
)
|
||||
|
||||
JWT_ALGORITHM = "HS256"
|
||||
|
||||
# 令牌默认有效期:7 天,可通过 .env 的 JWT_EXPIRE_MINUTES 覆盖
|
||||
JWT_EXPIRE_MINUTES = int(os.getenv("JWT_EXPIRE_MINUTES") or str(7 * 24 * 60))
|
||||
|
||||
# HTTP Bearer 令牌认证方案(auto_error=False:缺失令牌时不自动报错,便于统一错误返回格式)
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""使用 bcrypt 生成密码哈希,返回可直接存入数据库的字符串。"""
|
||||
salt = bcrypt.gensalt()
|
||||
return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
"""校验明文密码与 bcrypt 哈希是否匹配。"""
|
||||
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
|
||||
|
||||
|
||||
def create_access_token(user: User) -> str:
|
||||
"""生成 JWT 令牌:载荷包含用户 id、角色与过期时间。"""
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": str(user.id), # 用户 id(subject)
|
||||
"role": user.role,
|
||||
"ver": user.token_version, # 令牌版本(重置密码后旧令牌失效)
|
||||
"iat": now, # 签发时间
|
||||
"exp": now + timedelta(minutes=JWT_EXPIRE_MINUTES), # 过期时间
|
||||
}
|
||||
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
"""解析并校验 JWT 令牌,返回载荷;令牌无效或已过期时抛出 401。"""
|
||||
try:
|
||||
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
except jwt.PyJWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="登录状态无效或已过期,请重新登录",
|
||||
)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
"""FastAPI 依赖:从 Authorization: Bearer <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
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
数据库连接与初始化模块。
|
||||
|
||||
职责:
|
||||
- 建立 SQLite + SQLAlchemy 连接(引擎)
|
||||
- 提供会话工厂 SessionLocal 与 FastAPI 依赖 get_db
|
||||
- 提供数据库初始化函数 init_db()
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
|
||||
# 定位 backend 目录与项目根目录(与 backend 同级)
|
||||
BACKEND_DIR = Path(__file__).resolve().parent
|
||||
PROJECT_ROOT = BACKEND_DIR.parent
|
||||
|
||||
# 读取项目根目录下的 .env 配置文件(若存在)
|
||||
load_dotenv(PROJECT_ROOT / ".env")
|
||||
|
||||
# 数据库地址:默认使用 backend 目录下的 SQLite 文件,可通过 .env 的 DATABASE_URL 覆盖(留空视为使用默认值)
|
||||
DATABASE_URL = os.getenv("DATABASE_URL") or f"sqlite:///{(BACKEND_DIR / 'blog.db').as_posix()}"
|
||||
|
||||
# 创建 SQLAlchemy 引擎
|
||||
# SQLite 需关闭 check_same_thread:FastAPI 多线程处理请求时会跨线程复用会话
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {},
|
||||
)
|
||||
|
||||
# 会话工厂:每个请求创建独立会话,避免线程安全问题
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# 所有 ORM 模型的公共声明基类
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""FastAPI 依赖注入:提供数据库会话,请求结束后自动关闭。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""初始化数据库:创建所有已注册 ORM 模型对应的数据表。"""
|
||||
# 延迟导入模型,确保全部模型注册到 Base.metadata 后再建表
|
||||
from . import models # noqa: F401
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 支持直接运行:python -m backend.database
|
||||
init_db()
|
||||
print(f"数据库初始化完成:{DATABASE_URL}")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
邮件发送模块。
|
||||
|
||||
通过 SMTP 发送验证码邮件,配置项从 .env 读取:
|
||||
- SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_AUTH_CODE / EMAIL_FROM
|
||||
163 邮箱:smtp.163.com,端口 465(SSL),密码处填写授权码。
|
||||
"""
|
||||
|
||||
import os
|
||||
import smtplib
|
||||
from email.header import Header
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import formataddr
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from .database import PROJECT_ROOT
|
||||
|
||||
# 加载项目根目录下的 .env(重复调用无副作用)
|
||||
load_dotenv(PROJECT_ROOT / ".env")
|
||||
|
||||
SMTP_HOST = os.getenv("SMTP_HOST", "smtp.163.com")
|
||||
SMTP_PORT = int(os.getenv("SMTP_PORT") or "465")
|
||||
SMTP_USER = os.getenv("SMTP_USER", "")
|
||||
SMTP_AUTH_CODE = os.getenv("SMTP_AUTH_CODE", "")
|
||||
EMAIL_FROM = os.getenv("EMAIL_FROM", SMTP_USER)
|
||||
|
||||
|
||||
def send_verification_code(to_email: str, code: str) -> None:
|
||||
"""向指定邮箱发送验证码邮件;发送失败时抛出异常由调用方处理。"""
|
||||
if not SMTP_USER or not SMTP_AUTH_CODE:
|
||||
raise RuntimeError("SMTP 未配置:请在 .env 中设置 SMTP_USER / SMTP_AUTH_CODE")
|
||||
|
||||
subject = "博客 - 邮箱验证码"
|
||||
body = (
|
||||
f"您好,\n\n"
|
||||
f"您的验证码是:{code}\n"
|
||||
f"验证码 10 分钟内有效,请勿泄露给他人。\n\n"
|
||||
f"如非本人操作,请忽略本邮件。"
|
||||
)
|
||||
message = MIMEText(body, "plain", "utf-8")
|
||||
message["Subject"] = Header(subject, "utf-8")
|
||||
message["From"] = formataddr(("MyBlog", EMAIL_FROM))
|
||||
message["To"] = to_email
|
||||
|
||||
if SMTP_PORT == 465:
|
||||
server = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=15)
|
||||
else:
|
||||
server = smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=15)
|
||||
server.starttls()
|
||||
try:
|
||||
server.login(SMTP_USER, SMTP_AUTH_CODE)
|
||||
server.sendmail(EMAIL_FROM, [to_email], message.as_string())
|
||||
finally:
|
||||
server.quit()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
FastAPI 应用入口。
|
||||
|
||||
严格遵守全局架构,本文件只负责:
|
||||
1. 创建 FastAPI 实例
|
||||
2. 注册路由(router)
|
||||
3. 统一全局异常返回格式({success, data, message})
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from .routers import api_router
|
||||
from .schemas import UnifiedResponse
|
||||
|
||||
# 创建 FastAPI 实例
|
||||
app = FastAPI(
|
||||
title="MyBlog API",
|
||||
description="个人博客系统后端 API(FastAPI + SQLite + JWT)",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
|
||||
def _unified_error(status_code: int, message: str) -> JSONResponse:
|
||||
"""构造符合全局统一格式的错误响应。"""
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content=UnifiedResponse(success=False, data=None, message=message).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||
"""统一 HTTP 异常(业务错误)的返回格式。"""
|
||||
return _unified_error(exc.status_code, str(exc.detail))
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
"""统一请求参数校验错误的返回格式。"""
|
||||
return _unified_error(422, "请求参数校验失败")
|
||||
|
||||
|
||||
# 注册路由:后续任务在 routers 包中实现各业务路由后统一挂载
|
||||
app.include_router(api_router)
|
||||
@@ -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="创建时间")
|
||||
@@ -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)
|
||||
@@ -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="删除成功")
|
||||
@@ -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="获取成功")
|
||||
@@ -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)
|
||||
@@ -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="验证码校验通过",
|
||||
)
|
||||
@@ -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="已拒绝好友申请",
|
||||
)
|
||||
@@ -0,0 +1,264 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
SSH 白名单自动更新路由(阿里云安全组)。
|
||||
|
||||
背景:家庭宽带公网 IP 经常变化,安全组 22 端口若只放行固定 IP,
|
||||
换 IP 后就会把自己挡在门外。本路由让家庭电脑定时上报当前公网 IP,
|
||||
服务器调用阿里云 ECS API 自动更新安全组 22 端口的入方向白名单。
|
||||
|
||||
接口(统一响应格式 {success, data, message}):
|
||||
- POST /api/ipwatch/report 上报当前公网 IP,自动增删安全组 22 端口规则
|
||||
- GET /api/ipwatch/status 查看服务配置状态(不含任何密钥)
|
||||
|
||||
安全设计:
|
||||
- AccessKey 只存在于服务器 .env,家庭端只持有 IPWATCH_SECRET 上报密钥
|
||||
- 上报密钥用 hmac 恒定时间比较,并按来源 IP 限流(默认 60 秒一次)
|
||||
- 更新顺序“先加新规则、后删旧规则”,任何一步失败都不会让 SSH 完全断连
|
||||
- 只操作“tcp 22/22 且来源为单个 IP(/32)”的规则,绝不碰 0.0.0.0/0 等其它规则
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..schemas import UnifiedResponse
|
||||
from ..security import RateLimiter, get_client_ip
|
||||
|
||||
router = APIRouter(prefix="/api/ipwatch", tags=["ipwatch"])
|
||||
|
||||
# 日志走 uvicorn 的 logger,方便 journalctl 查看
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
|
||||
# ---------- 环境变量配置(服务器 .env,见 .env.example 中文注释) ----------
|
||||
|
||||
IPWATCH_SECRET = (os.getenv("IPWATCH_SECRET") or "").strip()
|
||||
ALIYUN_AK_ID = (os.getenv("ALIYUN_AK_ID") or "").strip()
|
||||
ALIYUN_AK_SECRET = (os.getenv("ALIYUN_AK_SECRET") or "").strip()
|
||||
SECURITY_GROUP_ID = (os.getenv("IPWATCH_SECURITY_GROUP_ID") or "").strip()
|
||||
IPWATCH_REGION = (os.getenv("IPWATCH_REGION") or "cn-hangzhou").strip()
|
||||
IPWATCH_PORT = int(os.getenv("IPWATCH_PORT") or "22")
|
||||
|
||||
# 同一来源 IP 两次上报的最小间隔(秒),防止密钥泄露后被刷白名单
|
||||
try:
|
||||
_report_interval = int(os.getenv("IPWATCH_REPORT_INTERVAL_SECONDS") or "60")
|
||||
except ValueError:
|
||||
_report_interval = 60
|
||||
report_limiter = RateLimiter(1, max(_report_interval, 1))
|
||||
|
||||
|
||||
class IpWatchReport(BaseModel):
|
||||
"""上报请求体:家庭端上传密钥与当前公网 IP。"""
|
||||
|
||||
secret: str = Field(min_length=1, max_length=256)
|
||||
ip: str = Field(min_length=7, max_length=45)
|
||||
|
||||
|
||||
def _aliyun_call(action: str, params: dict) -> dict:
|
||||
"""调用阿里云 ECS RPC API(HMAC-SHA1 签名,仅用 Python 标准库)。
|
||||
|
||||
Aliyun OpenAPI 签名规则:对全部参数按 key 排序后拼接,
|
||||
再用 AccessKeySecret 做 HMAC-SHA1,最后 BASE64 得到 Signature。
|
||||
"""
|
||||
query = {
|
||||
"AccessKeyId": ALIYUN_AK_ID,
|
||||
"Action": action,
|
||||
"Format": "JSON",
|
||||
"SignatureMethod": "HMAC-SHA1",
|
||||
"SignatureNonce": uuid.uuid4().hex, # 每次请求唯一,防止重放
|
||||
"SignatureVersion": "1.0",
|
||||
"Timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"Version": "2014-05-26",
|
||||
"RegionId": IPWATCH_REGION,
|
||||
}
|
||||
query.update(params or {})
|
||||
|
||||
def _enc(value: str) -> str:
|
||||
"""RFC3986 百分号编码(阿里云要求保留 -_.~ 三个字符)。"""
|
||||
return urllib.parse.quote(str(value), safe="-_.~")
|
||||
|
||||
canonical = "&".join(f"{_enc(k)}={_enc(v)}" for k, v in sorted(query.items()))
|
||||
string_to_sign = "GET&%2F&" + _enc(canonical)
|
||||
signature = base64.b64encode(
|
||||
hmac.new((ALIYUN_AK_SECRET + "&").encode(), string_to_sign.encode(), hashlib.sha1).digest()
|
||||
).decode()
|
||||
url = f"https://ecs.{IPWATCH_REGION}.aliyuncs.com/?{canonical}&Signature={_enc(signature)}"
|
||||
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "MyBlog-ipwatch/1.0"})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as exc:
|
||||
# 阿里云返回错误时,响应体里是 JSON 格式的错误信息
|
||||
try:
|
||||
return json.loads(exc.read().decode())
|
||||
except Exception:
|
||||
return {"Code": "HTTP_ERROR", "Message": f"阿里云接口返回 HTTP {exc.code}"}
|
||||
except Exception as exc:
|
||||
return {"Code": "NETWORK_ERROR", "Message": f"无法连接阿里云接口:{exc}"}
|
||||
|
||||
|
||||
def _fetch_ingress_rules() -> list:
|
||||
"""读取安全组全部入方向规则,失败时抛出 502。"""
|
||||
resp = _aliyun_call(
|
||||
"DescribeSecurityGroupAttribute",
|
||||
{"SecurityGroupId": SECURITY_GROUP_ID, "Direction": "ingress"},
|
||||
)
|
||||
if resp.get("Code"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"读取安全组规则失败:{resp.get('Message') or resp.get('Code')}",
|
||||
)
|
||||
return resp.get("Permissions", {}).get("Permission", [])
|
||||
|
||||
|
||||
def _host_ip(cidr: str) -> str:
|
||||
"""把安全组来源归一化为纯 IP;非 IPv4 单 IP(/32) 返回空字符串。
|
||||
|
||||
阿里云返回的 SourceCidrIp 可能是 "1.2.3.4" 或 "1.2.3.4/32",
|
||||
本函数只认 IPv4 的 /32 单 IP,其它(0.0.0.0/0、IPv6 等)一律忽略,
|
||||
防止误删用户手动配置的放行规则。
|
||||
"""
|
||||
if not cidr:
|
||||
return ""
|
||||
value = cidr.strip()
|
||||
if "/" not in value:
|
||||
try:
|
||||
ip = ipaddress.ip_address(value)
|
||||
except ValueError:
|
||||
return ""
|
||||
return str(ip) if ip.version == 4 else ""
|
||||
try:
|
||||
network = ipaddress.ip_network(value, strict=False)
|
||||
except ValueError:
|
||||
return ""
|
||||
if network.version != 4 or network.prefixlen != 32:
|
||||
return ""
|
||||
return str(network.network_address)
|
||||
|
||||
|
||||
@router.get("/status", response_model=UnifiedResponse)
|
||||
def status_info() -> UnifiedResponse:
|
||||
"""查看服务配置状态(不返回任何密钥),便于部署后排查。"""
|
||||
data = {
|
||||
"configured": bool(IPWATCH_SECRET and ALIYUN_AK_ID and ALIYUN_AK_SECRET and SECURITY_GROUP_ID),
|
||||
"region": IPWATCH_REGION,
|
||||
"security_group_id": SECURITY_GROUP_ID,
|
||||
"port": IPWATCH_PORT,
|
||||
"report_interval_seconds": report_limiter.window_seconds,
|
||||
}
|
||||
return UnifiedResponse(success=True, data=data, message="获取成功")
|
||||
|
||||
|
||||
@router.post("/report", response_model=UnifiedResponse)
|
||||
def report(payload: IpWatchReport, request: Request) -> UnifiedResponse:
|
||||
"""上报当前公网 IP:先加新白名单、再删旧白名单,全程不会锁死 SSH。"""
|
||||
# 1. 服务配置检查(未配置时直接拒绝,避免密钥为空被绕过)
|
||||
if not IPWATCH_SECRET:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="IP 白名单服务未配置")
|
||||
if not (ALIYUN_AK_ID and ALIYUN_AK_SECRET):
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="阿里云密钥未配置")
|
||||
if not SECURITY_GROUP_ID:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="安全组未配置")
|
||||
|
||||
# 2. 上报密钥校验(恒定时间比较,防止时序侧信道)
|
||||
if not hmac.compare_digest(payload.secret.encode("utf-8"), IPWATCH_SECRET.encode("utf-8")):
|
||||
logger.warning("ipwatch: 上报密钥错误,来源 %s", get_client_ip(request))
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="上报密钥错误")
|
||||
|
||||
# 3. 按来源 IP 限流,防止密钥泄露后被无限刷白名单
|
||||
client_ip = get_client_ip(request)
|
||||
if report_limiter.is_blocked(client_ip):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="上报过于频繁,请稍后再试")
|
||||
|
||||
# 4. IP 格式校验(仅支持 IPv4)
|
||||
ip_text = payload.ip.strip()
|
||||
try:
|
||||
ip_obj = ipaddress.ip_address(ip_text)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="IP 地址格式不正确")
|
||||
if ip_obj.version != 4:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 IPv4 地址")
|
||||
new_ip = str(ip_obj)
|
||||
new_cidr = f"{new_ip}/32"
|
||||
target_range = f"{IPWATCH_PORT}/{IPWATCH_PORT}"
|
||||
|
||||
# 5. 读取当前 22 端口规则,判断是否需要更新
|
||||
rules = _fetch_ingress_rules()
|
||||
port_rules = [
|
||||
r
|
||||
for r in rules
|
||||
if str(r.get("IpProtocol", "")).lower() == "tcp" and r.get("PortRange") == target_range
|
||||
]
|
||||
already_authorized = any(_host_ip(r.get("SourceCidrIp")) == new_ip for r in port_rules)
|
||||
if already_authorized:
|
||||
return UnifiedResponse(
|
||||
success=True,
|
||||
data={"changed": False, "ip": new_ip, "port": IPWATCH_PORT},
|
||||
message="IP 已在白名单中,无需更新",
|
||||
)
|
||||
|
||||
# 6. 先添加新 IP 规则(成功后才进入删除阶段,避免 SSH 断连)
|
||||
add_resp = _aliyun_call(
|
||||
"AuthorizeSecurityGroup",
|
||||
{
|
||||
"SecurityGroupId": SECURITY_GROUP_ID,
|
||||
"IpProtocol": "tcp",
|
||||
"PortRange": target_range,
|
||||
"SourceCidrIp": new_cidr,
|
||||
"Policy": "accept",
|
||||
"Description": "MyBlog IPWatch 自动白名单",
|
||||
},
|
||||
)
|
||||
if add_resp.get("Code"):
|
||||
logger.error("ipwatch: 添加白名单失败 %s -> %s", new_cidr, add_resp)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"添加白名单失败:{add_resp.get('Message') or add_resp.get('Code')}",
|
||||
)
|
||||
|
||||
# 7. 删除旧的单 IP 规则(只删 22 端口 /32 来源,绝不动 0.0.0.0/0 等规则)
|
||||
removed = []
|
||||
for rule in port_rules:
|
||||
old_ip = _host_ip(rule.get("SourceCidrIp"))
|
||||
if not old_ip or old_ip == new_ip:
|
||||
continue
|
||||
# 阿里云对旧规则的存储格式不统一(有的带 /32,有的不带),
|
||||
# 依次尝试两种格式;任一成功即视为删除完成
|
||||
revoke_ok = False
|
||||
for cidr_candidate in (f"{old_ip}/32", old_ip):
|
||||
revoke_resp = _aliyun_call(
|
||||
"RevokeSecurityGroup",
|
||||
{
|
||||
"SecurityGroupId": SECURITY_GROUP_ID,
|
||||
"IpProtocol": "tcp",
|
||||
"PortRange": target_range,
|
||||
"SourceCidrIp": cidr_candidate,
|
||||
"Policy": "accept",
|
||||
},
|
||||
)
|
||||
if not revoke_resp.get("Code"):
|
||||
revoke_ok = True
|
||||
break
|
||||
if revoke_ok:
|
||||
removed.append(old_ip)
|
||||
else:
|
||||
# 新规则已生效,旧规则删除失败只是残留,不影响 SSH 可用性
|
||||
logger.warning("ipwatch: 删除旧规则失败 %s -> %s", old_ip, revoke_resp)
|
||||
|
||||
report_limiter.hit(client_ip)
|
||||
data = {"changed": True, "ip": new_ip, "port": IPWATCH_PORT, "removed": removed}
|
||||
logger.info("ipwatch: 白名单更新完成 新IP=%s 删除=%s", new_ip, removed)
|
||||
return UnifiedResponse(success=True, data=data, message="白名单更新成功")
|
||||
@@ -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="获取成功")
|
||||
@@ -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="密码重置成功,请使用新密码登录")
|
||||
@@ -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="项目已删除")
|
||||
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
文件上传路由。
|
||||
|
||||
接口(统一响应格式 {success, data, message}):
|
||||
- POST /api/upload/avatar 头像上传(登录用户;jpg/png/webp,最大 2MB;成功后自动保存到头像字段)
|
||||
- POST /api/upload/article 文章图片上传(仅博主;jpg/png/webp,最大 6MB)
|
||||
- POST /api/upload/project 项目文件上传(仅博主;zip,最大 50MB)
|
||||
- POST /api/upload/doc 文章文档上传(仅博主;doc/docx,默认最大 20MB;仅用于生活/学习分区)
|
||||
|
||||
安全规则:
|
||||
- 随机文件名(uuid),不使用用户原始文件名,杜绝路径穿越
|
||||
- 校验扩展名 + 文件魔数(内容真实性),防止伪装类型
|
||||
- 文件仅作为静态资源由 Nginx 通过 /uploads/ 访问,禁止执行
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, UploadFile, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..database import PROJECT_ROOT, get_db
|
||||
from ..models import (
|
||||
ARTICLE_CATEGORY_LIFE,
|
||||
ARTICLE_CATEGORY_STUDY,
|
||||
ROLE_BLOGGER,
|
||||
ROLE_FRIEND,
|
||||
User,
|
||||
utcnow,
|
||||
)
|
||||
from ..schemas import UnifiedResponse
|
||||
|
||||
router = APIRouter(prefix="/api/upload", tags=["upload"])
|
||||
|
||||
# 上传根目录(可通过 .env 的 UPLOADS_DIR 覆盖;部署时 Nginx 将 /uploads/ 静态映射到该目录)
|
||||
UPLOADS_ROOT = Path(os.getenv("UPLOADS_DIR") or str(PROJECT_ROOT / "uploads"))
|
||||
|
||||
# 允许的扩展名(值仅用于展示)
|
||||
ALLOWED_IMAGES = {"jpg", "jpeg", "png", "webp"}
|
||||
ALLOWED_VIDEO = {"mp4", "webm"}
|
||||
ALLOWED_PROJECT = {"zip"}
|
||||
ALLOWED_DOC = {"doc", "docx"}
|
||||
|
||||
# 大小上限(字节):头像上限可通过 .env 的 AVATAR_MAX_SIZE_MB 调整(默认 4MB)
|
||||
MAX_AVATAR_SIZE = int(os.getenv("AVATAR_MAX_SIZE_MB", "4")) * 1024 * 1024
|
||||
# 文章图片(封面/正文插图)大小上限:可通过 .env 的 ARTICLE_IMAGE_MAX_SIZE_MB 调整(默认 6MB)
|
||||
MAX_IMAGE_SIZE = int(os.getenv("ARTICLE_IMAGE_MAX_SIZE_MB", "6")) * 1024 * 1024
|
||||
MAX_VIDEO_SIZE = 100 * 1024 * 1024 # 文章视频 100MB
|
||||
MAX_PROJECT_SIZE = 50 * 1024 * 1024 # 项目文件 50MB
|
||||
# 文章文档(doc/docx)大小上限:可通过 .env 的 DOC_MAX_SIZE_MB 调整(默认 20MB)
|
||||
MAX_DOC_SIZE = int(os.getenv("DOC_MAX_SIZE_MB", "20")) * 1024 * 1024
|
||||
|
||||
|
||||
def check_magic_bytes(content: bytes, ext: str) -> bool:
|
||||
"""校验文件魔数,防止伪造扩展名。"""
|
||||
if ext in ("jpg", "jpeg"):
|
||||
return content[:3] == b"\xff\xd8\xff"
|
||||
if ext == "png":
|
||||
return content[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
if ext == "webp":
|
||||
return content[:4] == b"RIFF" and content[8:12] == b"WEBP"
|
||||
if ext == "mp4":
|
||||
return content[4:8] == b"ftyp"
|
||||
if ext == "webm":
|
||||
return content[:4] == b"\x1aE\xdf\xa3"
|
||||
if ext == "zip":
|
||||
return content[:4] in (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08")
|
||||
if ext == "doc":
|
||||
# Word 97-2003:OLE 复合文档魔数
|
||||
return content[:8] == b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
|
||||
if ext == "docx":
|
||||
# Word 2007+:本质是 zip 压缩包
|
||||
return content[:4] in (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08")
|
||||
return False
|
||||
|
||||
|
||||
def save_upload(file: UploadFile, subdir: str, allowed: set, max_size: int) -> dict:
|
||||
"""校验并保存上传文件,返回 URL 等元信息。"""
|
||||
original_name = file.filename or ""
|
||||
ext = original_name.rsplit(".", 1)[-1].lower() if "." in original_name else ""
|
||||
if ext not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"不支持的文件类型(允许:{' / '.join(sorted(allowed))})",
|
||||
)
|
||||
|
||||
# 一次读入,超出上限直接拒绝(+1 用于判断是否超限)
|
||||
content = file.file.read(max_size + 1)
|
||||
if len(content) > max_size:
|
||||
raise HTTPException(status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="文件大小超出限制")
|
||||
|
||||
if not check_magic_bytes(content, ext):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件内容与扩展名不符")
|
||||
|
||||
# 随机文件名,避免覆盖与路径穿越
|
||||
saved_name = f"{uuid.uuid4().hex}.{ext}"
|
||||
target_dir = UPLOADS_ROOT / subdir
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
(target_dir / saved_name).write_bytes(content)
|
||||
|
||||
return {
|
||||
"url": f"/uploads/{subdir}/{saved_name}",
|
||||
"filename": original_name,
|
||||
"size": len(content),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/avatar", response_model=UnifiedResponse)
|
||||
def upload_avatar(
|
||||
file: UploadFile,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""头像上传:仅好友或博主可上传(访客除注册外禁止一切上传);成功后自动更新头像字段。
|
||||
更换频率限制:同一账号两次更换之间至少间隔 AVATAR_CHANGE_INTERVAL_HOURS 小时(默认 24)。
|
||||
"""
|
||||
if current_user.role not in (ROLE_FRIEND, ROLE_BLOGGER):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="访客不能上传头像")
|
||||
|
||||
# 更换冷却:距离上次更换不足 N 小时则拒绝,并提示剩余等待时间
|
||||
interval_hours = int(os.getenv("AVATAR_CHANGE_INTERVAL_HOURS", "24"))
|
||||
if current_user.avatar_updated_time is not None:
|
||||
elapsed = utcnow() - current_user.avatar_updated_time
|
||||
if elapsed < timedelta(hours=interval_hours):
|
||||
remaining = timedelta(hours=interval_hours) - elapsed
|
||||
hours = int(remaining.total_seconds() // 3600)
|
||||
minutes = int((remaining.total_seconds() % 3600) // 60)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"头像更换过于频繁,请约 {hours} 小时 {minutes} 分钟后再试",
|
||||
)
|
||||
|
||||
data = save_upload(file, "avatar", ALLOWED_IMAGES, MAX_AVATAR_SIZE)
|
||||
current_user.avatar = data["url"]
|
||||
current_user.avatar_updated_time = utcnow()
|
||||
db.commit()
|
||||
return UnifiedResponse(success=True, data=data, message="头像上传成功")
|
||||
|
||||
|
||||
@router.post("/article", response_model=UnifiedResponse)
|
||||
def upload_article_image(
|
||||
file: UploadFile,
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> UnifiedResponse:
|
||||
"""文章图片上传:仅博主可上传(用于封面与正文插图)。"""
|
||||
if current_user.role != ROLE_BLOGGER:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以上传文章图片")
|
||||
data = save_upload(file, "article", ALLOWED_IMAGES, MAX_IMAGE_SIZE)
|
||||
return UnifiedResponse(success=True, data=data, message="图片上传成功")
|
||||
|
||||
|
||||
@router.post("/video", response_model=UnifiedResponse)
|
||||
def upload_article_video(
|
||||
file: UploadFile,
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> UnifiedResponse:
|
||||
"""文章视频上传:仅博主可上传(mp4 / webm,最大 100MB),供正文插入视频。"""
|
||||
if current_user.role != ROLE_BLOGGER:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以上传文章视频")
|
||||
data = save_upload(file, "article", ALLOWED_VIDEO, MAX_VIDEO_SIZE)
|
||||
return UnifiedResponse(success=True, data=data, message="视频上传成功")
|
||||
|
||||
|
||||
@router.post("/project", response_model=UnifiedResponse)
|
||||
def upload_project_file(
|
||||
file: UploadFile,
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> UnifiedResponse:
|
||||
"""项目文件上传:仅博主可上传,支持 zip 压缩包(供下载项目使用)。"""
|
||||
if current_user.role != ROLE_BLOGGER:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以上传项目文件")
|
||||
data = save_upload(file, "project", ALLOWED_PROJECT, MAX_PROJECT_SIZE)
|
||||
return UnifiedResponse(success=True, data=data, message="项目文件上传成功")
|
||||
|
||||
@router.post("/doc", response_model=UnifiedResponse)
|
||||
def upload_article_doc(
|
||||
file: UploadFile,
|
||||
category: str = Form(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> UnifiedResponse:
|
||||
"""文章文档上传(doc / docx):仅博主可上传,且只能用于“我的生活 / 我的学习”分区的文章。"""
|
||||
if current_user.role != ROLE_BLOGGER:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以上传文档")
|
||||
if category not in (ARTICLE_CATEGORY_LIFE, ARTICLE_CATEGORY_STUDY):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="文档只能用于生活或学习分区的文章",
|
||||
)
|
||||
data = save_upload(file, "article", ALLOWED_DOC, MAX_DOC_SIZE)
|
||||
return UnifiedResponse(success=True, data=data, message="文档上传成功")
|
||||
@@ -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="资料已更新")
|
||||
@@ -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="")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,476 @@
|
||||
# MyBlog 部署指南(Ubuntu + Nginx + FastAPI)
|
||||
|
||||
本指南覆盖:服务器初始化 → 上传项目 → 安装依赖 → 配置 Nginx / systemd → 开启 HTTPS → 验证上线。
|
||||
假设你的阿里云服务器为 **Ubuntu 22.04/24.04**,已有公网 IP 与可登录的账号(root 或 sudo 用户)。
|
||||
|
||||
> 架构速览:Nginx 提供前端静态文件与 `/uploads/`,并把 `/api/*` 反代到
|
||||
> `127.0.0.1:8080` 的 FastAPI(只监听本机,公网无法直连);后端使用 SQLite,数据在
|
||||
> `/var/www/blog/backend/blog.db`。
|
||||
|
||||
---
|
||||
|
||||
## 一、本地准备:确认要上传的内容
|
||||
|
||||
项目根目录(`D:\MyBlog`)需要上传:
|
||||
|
||||
| 路径 | 说明 | 是否上传 |
|
||||
| --- | --- | --- |
|
||||
| `frontend/` | 前端全部文件(index.html / *.js / style.css) | ✅ |
|
||||
| `backend/` | 后端代码(不含 `blog.db`、`__pycache__`) | ✅ |
|
||||
| `uploads/` | 上传目录(可传已有文件,如头像 favicon.png) | ✅(可为空) |
|
||||
| `requirements.txt` | Python 依赖清单 | ✅ |
|
||||
| `.env.example` | 环境变量模板 | ✅ |
|
||||
| `README.md` | 项目说明 | ✅ |
|
||||
| `deploy/` | 部署文件(nginx.conf / myblog.service / 本指南) | ✅ |
|
||||
| `.env` | 含密钥与邮箱授权码,**不要上传**,到服务器上手动创建 | ❌ |
|
||||
| `backend/blog.db` | 本地测试数据库,**不要上传**,服务器上重新初始化 | ❌ |
|
||||
|
||||
### 1.1 Windows 打包并上传(自带 tar 与 scp)
|
||||
|
||||
```powershell
|
||||
# 在项目目录 D:\MyBlog 下执行:打包(排除本地环境文件)
|
||||
tar -czf myblog.tar.gz frontend backend uploads deploy requirements.txt .env.example README.md
|
||||
|
||||
# 上传到服务器 /tmp(把 root@IP 换成你的实际账号和公网 IP)
|
||||
scp myblog.tar.gz root@你的公网IP:/tmp/
|
||||
```
|
||||
|
||||
> 提示:Windows 10 1803+ 自带 `tar` / `scp`。也可用 WinSCP / Xftp 直接拖拽,
|
||||
> 或使用 Git Bash / WSL 里的 `rsync -avz --exclude='.env' --exclude='backend/blog.db' ./ root@IP:/var/www/blog/`。
|
||||
|
||||
---
|
||||
|
||||
## 二、阿里云安全组开放端口
|
||||
|
||||
登录阿里云控制台 → ECS 实例 → 安全组 → 配置规则 → 入方向,**仅开放以下端口**:
|
||||
|
||||
| 端口 | 用途 | 建议 |
|
||||
| --- | --- | --- |
|
||||
| 22 | SSH 远程管理 | 建议“指定源”只放行你的家庭/办公 IP |
|
||||
| 80 | HTTP(Nginx) | 0.0.0.0/0 |
|
||||
| 443 | HTTPS(Nginx) | 0.0.0.0/0(配合 certbot) |
|
||||
| 25565 | Minecraft(独立服务,与博客无关) | 按需开放 |
|
||||
|
||||
⚠️ **8080 端口千万不要开放**:FastAPI 只监听 `127.0.0.1:8080`,公网即使访问 8080 也会被拒绝;
|
||||
如果安全组放行 8080 而 Nginx 代理配置有误,等于把后端裸奔在公网上。
|
||||
|
||||
---
|
||||
|
||||
## 三、服务器初始化
|
||||
|
||||
SSH 登录服务器后执行:
|
||||
|
||||
```bash
|
||||
# 1. 系统更新 + 安装 Nginx / Python3 / venv / pip
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
sudo apt install -y nginx python3 python3-venv python3-pip
|
||||
|
||||
# 2. 创建博客专用系统账号(禁止登录,只用于运行后端)
|
||||
sudo useradd -r -m -s /usr/sbin/nologin blog
|
||||
|
||||
# 3. 创建部署目录
|
||||
sudo mkdir -p /var/www/blog
|
||||
sudo chown -R blog:blog /var/www/blog
|
||||
|
||||
# 4. 解压上传的项目包(由 /tmp 解压到 /var/www/blog)
|
||||
sudo mkdir -p /var/www/blog
|
||||
cd /tmp && sudo tar -xzf myblog.tar.gz -C /var/www/blog/
|
||||
sudo chown -R blog:blog /var/www/blog
|
||||
|
||||
# 5. 准备证书校验目录(Nginx 配置里用到)
|
||||
sudo mkdir -p /var/www/blog/.well-known/acme-challenge
|
||||
sudo chown -R blog:blog /var/www/blog/.well-known
|
||||
|
||||
# 6. 确认前端文件就位(页面引用绝对路径 /main.js,root 必须指向 frontend)
|
||||
ls /var/www/blog/frontend/index.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、配置 .env(服务器上手动创建)
|
||||
|
||||
`.env` 含密钥与授权码,必须到服务器上创建,不要从本地直接上传:
|
||||
|
||||
```bash
|
||||
cd /var/www/blog
|
||||
sudo -u blog cp .env.example .env
|
||||
sudo -u blog nano .env # 或 vi
|
||||
```
|
||||
|
||||
必填项(`nano` 里按 `Ctrl+O` 保存,`Ctrl+X` 退出):
|
||||
|
||||
```ini
|
||||
# 强随机密钥:在服务器上执行下面命令生成,然后粘贴进来
|
||||
# python3 -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
JWT_SECRET=这里粘贴生成的随机密钥
|
||||
|
||||
# 博主账号(首次执行 python -m backend.seed 时创建)
|
||||
BLOGGER_USERNAME=孤竹居士
|
||||
BLOGGER_EMAIL=guzhujushi2008@163.com
|
||||
BLOGGER_PASSWORD=你的登录密码
|
||||
|
||||
# SMTP 邮箱验证码(163 邮箱 + 授权码)
|
||||
SMTP_HOST=smtp.163.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=guzhujushi2008@163.com
|
||||
SMTP_AUTH_CODE=你的163授权码
|
||||
EMAIL_FROM=guzhujushi2008@163.com
|
||||
```
|
||||
|
||||
其余可选变量(限流、验证码、评论长度等)不填会使用默认值,说明见 `.env.example`。
|
||||
设置文件权限,防止他人读取密钥:
|
||||
|
||||
```bash
|
||||
sudo chmod 600 /var/www/blog/.env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、安装依赖 + 初始化数据库
|
||||
|
||||
```bash
|
||||
# 1. 创建虚拟环境并安装依赖(建议先换成国内 pip 镜像,速度更快)
|
||||
cd /var/www/blog
|
||||
sudo -u blog python3 -m venv venv
|
||||
sudo -u blog venv/bin/pip install -r requirements.txt
|
||||
# 可选加速:-i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# 2. 初始化数据库表(自动创建 backend/blog.db)
|
||||
sudo -u blog venv/bin/python -m backend.database
|
||||
|
||||
# 3. 创建唯一博主账号(读取 .env 里的 BLOGGER_*,重复执行会跳过)
|
||||
sudo -u blog venv/bin/python -m backend.seed
|
||||
|
||||
# 4. 检查上传目录可写
|
||||
sudo -u blog mkdir -p uploads/avatar uploads/article uploads/project
|
||||
sudo chown -R blog:blog /var/www/blog/uploads
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、部署 Nginx 配置
|
||||
|
||||
```bash
|
||||
# 1. 复制配置文件(内容见 deploy/nginx.conf)
|
||||
sudo cp /var/www/blog/deploy/nginx.conf /etc/nginx/sites-available/myblog
|
||||
|
||||
# 2. 把 server_name 改成你的域名(或公网 IP)
|
||||
sudo nano /etc/nginx/sites-available/myblog
|
||||
|
||||
# 3. 启用站点(删除默认站点,避免冲突)
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
sudo ln -s /etc/nginx/sites-available/myblog /etc/nginx/sites-enabled/
|
||||
|
||||
# 4. 校验配置并重载
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、部署 systemd 服务
|
||||
|
||||
```bash
|
||||
# 1. 复制服务文件(内容见 deploy/myblog.service)
|
||||
sudo cp /var/www/blog/deploy/myblog.service /etc/systemd/system/
|
||||
|
||||
# 2. 加载并启动
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now myblog
|
||||
|
||||
# 3. 确认状态:Active: active (running) 表示成功
|
||||
sudo systemctl status myblog
|
||||
```
|
||||
|
||||
**手动启动方式(调试用,生产请用 systemd)**:
|
||||
|
||||
```bash
|
||||
cd /var/www/blog
|
||||
sudo -u blog /var/www/blog/venv/bin/uvicorn backend.main:app --host 127.0.0.1 --port 8080
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、开启 HTTPS(Let's Encrypt,免费证书)
|
||||
|
||||
```bash
|
||||
# 1. 安装 certbot 的 Nginx 插件
|
||||
sudo apt install -y certbot python3-certbot-nginx
|
||||
|
||||
# 2. 一键签发并自动改写 Nginx 配置(会自动加 443 监听与自动续期)
|
||||
sudo certbot --nginx -d your-domain.com
|
||||
|
||||
# 3. 证书自动续期(certbot 已内置定时任务,可验证)
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
HTTPS 配置完成后:
|
||||
- 80 端口会 301 跳转到 443(certbot 自动处理)
|
||||
- 记得把安全组 443 放行(见第二节)
|
||||
- 建议把 `.env` 里的 `EMAIL_FROM` 等保持不变即可
|
||||
|
||||
---
|
||||
|
||||
## 九、上线验证清单
|
||||
|
||||
```bash
|
||||
# 1. 后端接口是否通(应返回 {"success":true,...})
|
||||
curl http://127.0.0.1:8080/api/article/list
|
||||
|
||||
# 2. 前端首页是否可访问(应返回 index.html)
|
||||
curl -I http://127.0.0.1:8080/ # 本机访问后端正常(证明服务活着)
|
||||
curl -I http://你的公网IP/ # 公网走 Nginx 访问首页
|
||||
# 公网直接访问 http://公网IP:8080 应超时/拒绝(8080 未放行且只监听本机)
|
||||
|
||||
# 3. SPA 刷新是否正常(直接访问子路由应回退到 index.html)
|
||||
curl -I http://你的公网IP/partition/4
|
||||
|
||||
# 4. 上传目录是否可访问(favicon 应为 200)
|
||||
curl -I http://你的公网IP/uploads/avatar/favicon.png
|
||||
|
||||
# 5. 查看后端日志是否有报错
|
||||
journalctl -u myblog -f
|
||||
|
||||
# 6. 浏览器验证全流程:
|
||||
# 打开首页 → 注册/登录 → 文章列表 → 文章详情 → 评论/点赞 → 好友申请
|
||||
# (博主)登录 → 发文管理 → 上传头像/封面/项目 → 审批好友申请
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、常见问题
|
||||
|
||||
| 现象 | 排查方向 |
|
||||
| --- | --- |
|
||||
| 页面能打开但接口报错 | `journalctl -u myblog` 看后端日志;确认 `.env` 里 `JWT_SECRET` 已配置 |
|
||||
| 上传图片 404 | `uploads/` 目录权限:`sudo chown -R blog:blog /var/www/blog/uploads` |
|
||||
| 刷新子页面 404 | Nginx `try_files $uri $uri/ /index.html;` 是否在 `location /` 中 |
|
||||
| 博主登录失败多次被锁 | 安全组限流策略(默认 5 次/15 分钟),等窗口过期或调大 `.env` 阈值 |
|
||||
| 验证码发不出去 | 检查 `.env` 的 SMTP 配置与 163 授权码;服务器 465 端口出方向是否被云安全组限制 |
|
||||
| 修改代码后不生效 | 后端:`sudo systemctl restart myblog`;前端:用 `deploy/update.ps1` 部署(自动换版本号,浏览器强制拉新文件);若仍异常,Ctrl+F5 强刷一次 |
|
||||
| 8080 被公网扫到 | 确认 uvicorn 只监听 `127.0.0.1`,且安全组未放行 8080 |
|
||||
|
||||
---
|
||||
|
||||
## 十一、日常运维
|
||||
|
||||
```bash
|
||||
# 查看状态 / 重启 / 日志
|
||||
sudo systemctl status myblog
|
||||
sudo systemctl restart myblog
|
||||
journalctl -u myblog -n 100
|
||||
|
||||
# 备份数据库与上传文件(SQLite 单文件 + 目录)
|
||||
sudo tar -czf backup_$(date +%F).tar.gz -C /var/www/blog backend/blog.db uploads
|
||||
|
||||
# 升级代码流程:见下方「更新代码到服务器(增量同步 + 自动重启)」
|
||||
```
|
||||
|
||||
### 更新代码到服务器(增量同步 + 自动重启)
|
||||
|
||||
修改完本地代码后,把改动同步到服务器并重启后端,**一条命令完成**:
|
||||
|
||||
```bash
|
||||
# 在本地 Git Bash / WSL 的项目目录执行(root 登录时无需 sudo)
|
||||
rsync -avz \
|
||||
--exclude='.env' \
|
||||
--exclude='backend/blog.db' \
|
||||
--exclude='venv' \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='uploads/' \
|
||||
./ root@你的公网IP:/var/www/blog/ \
|
||||
&& ssh root@你的公网IP 'systemctl restart myblog'
|
||||
```
|
||||
|
||||
命令做了什么:
|
||||
- `rsync` 只把**本地改动过的文件**增量同步到服务器,未变化的自动跳过;
|
||||
`&&` 表示同步成功后继续通过 SSH 执行重启 → 改完代码跑这一条,就是一次完整更新。
|
||||
- 只改前端(`frontend/`)时后端无需重启;重启约 1 秒、无副作用,想省心可每次都执行。
|
||||
|
||||
注意:
|
||||
- **不要加 `--delete`**:会删除服务器上本地没有的文件(例如用户通过网页上传的图片)。
|
||||
- 排除 `uploads/`:它是服务器上的运行时数据(网页上传的文件只存在于服务器)。
|
||||
若本地新增了静态资源(如头像 favicon)需要上传,去掉 `--exclude='uploads/'` 再执行。
|
||||
- 非 root 用户:把 `root@` 换成你的用户名,并先在服务器上配置该命令的免密 sudo:
|
||||
|
||||
```bash
|
||||
# 服务器上执行(username 换成你的登录用户)
|
||||
sudo visudo -f /etc/sudoers.d/blog-update
|
||||
# 在文件里加入下面一行后保存:
|
||||
# username ALL=(ALL) NOPASSWD: /bin/systemctl restart myblog
|
||||
```
|
||||
|
||||
之后本地执行:
|
||||
|
||||
```bash
|
||||
rsync -avz --exclude='.env' --exclude='backend/blog.db' --exclude='venv' --exclude='__pycache__' --exclude='uploads/' ./ username@你的公网IP:/var/www/blog/ && ssh username@你的公网IP 'sudo systemctl restart myblog'
|
||||
```
|
||||
|
||||
验证更新是否生效:
|
||||
|
||||
```bash
|
||||
ssh root@你的公网IP 'systemctl status myblog'
|
||||
curl -s http://你的公网IP/api/article/list # 应返回 {"success":true,...}
|
||||
```
|
||||
|
||||
|
||||
### 一键更新脚本(推荐,Windows 上直接用)
|
||||
|
||||
项目自带两个脚本,不需要 rsync / Git Bash,Windows 打开 PowerShell 即可:
|
||||
|
||||
| 文件 | 作用 |
|
||||
| --- | --- |
|
||||
| `deploy/update.ps1` | 本地一键脚本:打包代码 -> 上传 -> 触发服务器更新 |
|
||||
| `deploy/update_server.sh` | 服务器端脚本:备份 -> 解压 -> 数据库迁移 -> 重启 -> 验证 |
|
||||
| `deploy/deploy.conf` | 配置服务器地址与域名(`SERVER=` / `DOMAIN=`) |
|
||||
|
||||
用法:
|
||||
|
||||
```powershell
|
||||
# 在项目根目录 D:\MyBlog 下执行
|
||||
powershell -ExecutionPolicy Bypass -File .\deploy\update.ps1
|
||||
|
||||
# 或指定服务器(覆盖 deploy.conf)
|
||||
powershell -ExecutionPolicy Bypass -File .\deploy\update.ps1 -Server root@8.145.36.108
|
||||
|
||||
# 需要把本地 uploads/ 一并同步时(首次部署 / 新增静态资源)
|
||||
powershell -ExecutionPolicy Bypass -File .\deploy\update.ps1 -IncludeUploads
|
||||
```
|
||||
|
||||
脚本会做的事(与上面 rsync 方案等价且更省心):
|
||||
- 打包时自动排除 `.env`、`backend/blog.db`、`__pycache__`、`venv`,默认也排除 `uploads/`
|
||||
- 每次更新前在服务器上自动备份到 `/root/blog-backups/`(含数据库与上传文件),可回滚
|
||||
- 自动执行幂等数据库迁移(补齐 `articles.category` 等新列),并重启 `myblog` 服务
|
||||
- 结束后自动验证后端接口与线上 HTTPS 是否正常
|
||||
|
||||
### 前端缓存自动清理(更新后无需手动 Ctrl+F5)
|
||||
|
||||
页面引用的 `style.css` / `main.js` 已带版本号 `?v=__VERSION__`:
|
||||
|
||||
- 每次执行 `deploy/update.ps1` 部署时,服务器端会自动把 `__VERSION__` 替换成**当前时间戳**
|
||||
- 版本号一变化,浏览器就会重新下载最新 JS/CSS,旧缓存自动失效,无需手动清理
|
||||
- Nginx 同时对前端文件返回 `Cache-Control: no-cache`(每次重新校验),双保险
|
||||
- 如需手动验证:`curl -s https://你的域名/ | grep main.js` 应能看到带版本号的引用
|
||||
|
||||
### SSH 白名单自动更新(家庭公网 IP 变化不锁死)
|
||||
|
||||
家庭宽带公网 IP 经常变化,若安全组 22 端口只放行固定 IP,换 IP 后 SSH 会被自己挡在门外。
|
||||
本项目提供“家庭端定时上报 + 服务器端自动更新安全组”的完整方案,文件均在 `deploy/ipwatch/`。
|
||||
|
||||
```
|
||||
家庭电脑 report.ps1(每 30 分钟)---> 博客 /myip 获取当前公网 IP
|
||||
| IP 有变化时
|
||||
└--> POST /api/ipwatch/report(携带密钥)
|
||||
服务器调用阿里云 ECS API:先新增新 IP 规则,成功后再删除旧规则(绝不锁死 SSH)
|
||||
```
|
||||
|
||||
**服务器端配置(一次性):**
|
||||
|
||||
1. 在服务器 `.env` 中追加以下变量(AccessKey 只放服务器,不要发给家庭端):
|
||||
|
||||
```ini
|
||||
# 生成随机上报密钥:python3 -c "import secrets; print(secrets.token_urlsafe(24))"
|
||||
IPWATCH_SECRET=这里粘贴随机密钥
|
||||
ALIYUN_AK_ID=你的RAM子账号AK
|
||||
ALIYUN_AK_SECRET=你的RAM子账号SK
|
||||
IPWATCH_SECURITY_GROUP_ID=sg-0jl65y8luej10xz8neli
|
||||
IPWATCH_REGION=cn-wulanchabu
|
||||
IPWATCH_PORT=22
|
||||
```
|
||||
|
||||
2. RAM 子账号只授予该安全组的查询/增/删规则权限(`ecs:DescribeSecurityGroupAttribute`、
|
||||
`ecs:AuthorizeSecurityGroup`、`ecs:RevokeSecurityGroup`),不要使用主账号密钥。
|
||||
3. 重新部署代码后重启服务:`sudo systemctl restart myblog`
|
||||
4. 验证服务状态:`curl -s http://127.0.0.1:8080/api/ipwatch/status`,应返回 `configured: true`。
|
||||
|
||||
**家庭端安装(Windows,一次性):**
|
||||
|
||||
```powershell
|
||||
cd deploy\ipwatch
|
||||
Copy-Item ipwatch.conf.example ipwatch.conf
|
||||
# 用记事本编辑 ipwatch.conf:SECRET 填与服务器 IPWATCH_SECRET 相同的值
|
||||
powershell -ExecutionPolicy Bypass -File .\install.ps1 # 注册每 30 分钟的计划任务
|
||||
Get-Content .\ipwatch.log # 查看首次运行结果
|
||||
```
|
||||
|
||||
**验证方法:**
|
||||
|
||||
- 手动模拟一次 IP 变化:调用 `POST /api/ipwatch/report` 上报一个测试 IP,再到阿里云控制台
|
||||
或 `deploy/ipwatch` 检查安全组 22 端口规则,确认“先加后删”;随后再上报回真实 IP。
|
||||
- 说明:测试期间新规则替换旧规则,SSH 会短暂不可用,测试后必须恢复真实 IP。
|
||||
|
||||
**注意事项:**
|
||||
|
||||
- 安全组中其它端口(80/443/25565 等)和 `0.0.0.0/0` 规则不会被脚本触碰,
|
||||
它只操作“tcp 22/22 且来源为单个 IP(/32)”的规则。
|
||||
- 上报接口按来源 IP 限流(默认 60 秒一次,`IPWATCH_REPORT_INTERVAL_SECONDS` 可调)。
|
||||
- 若上报密钥泄露:改服务器 `.env` 的 `IPWATCH_SECRET` → 重启服务 → 同步改家庭端 `ipwatch.conf`。
|
||||
- 阿里云 AccessKey 建议定期在 RAM 控制台轮换,轮换后同步更新服务器 `.env`。
|
||||
|
||||
|
||||
### JS→WASM 预编译(把上传项目的 JS 编译为 WASM)
|
||||
|
||||
后端提供“把项目演示目录里的 .js 预编译为 .wasm”的功能(基于 Javy / QuickJS),
|
||||
编译产物与报告存放在 `uploads/demos/{项目id}/wasm/`。
|
||||
|
||||
**重要说明(先看这里):**
|
||||
|
||||
- 浏览器页面**仍然运行原始 JS**。Javy 产物是“QuickJS 解释器 + 字节码”,不能操作
|
||||
DOM,速度也不如浏览器自带的 V8 JIT,所以它**不能替代**演示页里的 JS。
|
||||
- 这个功能的真正价值是**服务端沙箱执行**:把不可信的 JS 关进 WASM 沙箱里运行,
|
||||
隔离文件/网络/系统访问(符合“禁止直接运行用户上传代码”的安全规范)。
|
||||
|
||||
**安装编译工具(一次性,服务器上执行):**
|
||||
|
||||
```bash
|
||||
cd /var/www/blog
|
||||
bash deploy/install_javy.sh # 下载约 14MB,安装到 /usr/local/bin/javy
|
||||
javy --version # 验证:应输出 javy 9.1.0
|
||||
```
|
||||
|
||||
体积与性能分析:
|
||||
|
||||
- Javy 官方 Linux 工具包:约 14MB(下载)/ 约 40MB(解压后),磁盘足够即可。
|
||||
- 编译产物大小:每个 .wasm 约 300KB~1MB(内含 QuickJS 运行时快照 + 你的 JS 字节码)。
|
||||
- 编译速度:普通几十 KB 的 JS 文件 1 秒内完成;总耗时与文件数成正比。
|
||||
- 运行性能:QuickJS 是解释器,同类计算基准通常比 V8 JIT 慢数倍到十几倍;
|
||||
用于沙箱隔离/短任务没问题,不适合做重计算。
|
||||
|
||||
**下载压缩包也会被一起打包为 wasm:**
|
||||
|
||||
- 每次预编译时,除了演示目录里的 .js,还会把项目的下载压缩包(uploads/project/{uuid}.zip)
|
||||
内的全部 JS 合并打包为单个 wasm,输出到同目录 `uploads/project/{uuid}.wasm`(可直接下载)。
|
||||
- 说明:zip 本身不是 JS,无法直接编译;这里是把 zip 内的 .js 按文件名排序拼接成一个
|
||||
临时 bundle 再交给 Javy,产物是“压缩包的 wasm 版本”(演示页仍运行原始 JS)。
|
||||
- 总量上限默认 10MB(`WASM_BUNDLE_MAX_SIZE_MB` 可调),超过会跳过打包并在报告中注明。
|
||||
|
||||
**使用方式(任选其一):**
|
||||
|
||||
```bash
|
||||
# 方式一:服务器上手动触发(推荐先用这个验证)
|
||||
bash deploy/precompile_wasm.sh 1
|
||||
|
||||
# 方式二:博主登录后调用接口
|
||||
curl -X POST http://127.0.0.1:8080/api/project/1/precompile -H "Authorization: Bearer <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)。
|
||||
@@ -0,0 +1,5 @@
|
||||
# MyBlog 部署配置(供 update.ps1 读取,可自行修改)
|
||||
# 服务器地址:SSH 登录格式 user@公网IP(必须已配置 SSH 免密登录)
|
||||
SERVER=root@8.145.36.108
|
||||
# 域名:用于更新后验证 HTTPS 是否正常;没有域名可留空
|
||||
DOMAIN=guzhujushi.cn
|
||||
@@ -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"
|
||||
@@ -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 规则。
|
||||
@@ -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)"
|
||||
@@ -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 相同的值
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# 卸载 MyBlog IP 白名单定时任务
|
||||
$ErrorActionPreference = "Stop"
|
||||
& schtasks /Delete /F /TN "MyBlogIPWatch" | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) { Write-Host "计划任务 MyBlogIPWatch 已删除" }
|
||||
@@ -0,0 +1,49 @@
|
||||
# ============================================================
|
||||
# MyBlog 后端 systemd 服务
|
||||
# ------------------------------------------------------------
|
||||
# 部署位置:/etc/systemd/system/myblog.service
|
||||
# 生效命令:
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable --now myblog # 开机自启 + 立即启动
|
||||
# 常用命令:
|
||||
# sudo systemctl status myblog # 查看状态
|
||||
# sudo systemctl restart myblog # 重启
|
||||
# journalctl -u myblog -f # 跟踪日志
|
||||
# ============================================================
|
||||
|
||||
[Unit]
|
||||
Description=MyBlog FastAPI Backend
|
||||
# 等待网络就绪后再启动(uvicorn 需要绑定端口)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
# 专用运行账号:系统用户、禁止登录,只拥有项目文件权限
|
||||
User=blog
|
||||
Group=blog
|
||||
|
||||
# 工作目录必须是项目根目录(backend 包、.env、uploads 都相对它定位)
|
||||
WorkingDirectory=/var/www/blog
|
||||
|
||||
# 启动命令:使用项目虚拟环境里的 uvicorn,只监听本机 8080(禁止公网直连)
|
||||
ExecStart=/var/www/blog/venv/bin/uvicorn backend.main:app --host 127.0.0.1 --port 8080
|
||||
|
||||
# 崩溃后 3 秒自动重启;开机自启由 enable 管理
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
# 环境变量说明:
|
||||
# .env 不需要写进 EnvironmentFile —— 应用启动时会在代码里
|
||||
# 自动读取项目根目录 .env(backend/database.py 的 load_dotenv),
|
||||
# 这样也能避免 systemd 解析中文值/注释行时出错。
|
||||
# 部署时请务必在 /var/www/blog/.env 中配置 JWT_SECRET,否则后端拒绝启动。
|
||||
|
||||
# ---------- 安全加固(可选) ----------
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
# 允许写入:上传目录(用户上传文件)与 backend 目录(SQLite 数据库 backend/blog.db)
|
||||
ReadWritePaths=/var/www/blog/uploads /var/www/blog/backend
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,106 @@
|
||||
# ============================================================
|
||||
# MyBlog Nginx 站点配置
|
||||
# ------------------------------------------------------------
|
||||
# 部署位置:/etc/nginx/sites-available/myblog
|
||||
# 启用命令:sudo ln -s /etc/nginx/sites-available/myblog /etc/nginx/sites-enabled/
|
||||
# 校验命令:sudo nginx -t
|
||||
# 重载命令:sudo systemctl reload nginx
|
||||
#
|
||||
# 目录约定(与全局架构一致):
|
||||
# /var/www/blog/frontend 前端静态文件(index.html / *.js / style.css)
|
||||
# /var/www/blog/uploads 上传文件(avatar / article / project)
|
||||
# /var/www/blog/backend 后端代码
|
||||
# /var/www/blog/.env 环境变量(应用启动时自动加载)
|
||||
# ============================================================
|
||||
|
||||
# ============ HTTP 80:普通访问 + 证书校验 ============
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
# 改成你的域名(或服务器公网 IP);certbot 会自动填入证书相关配置
|
||||
server_name your-domain.com;
|
||||
# 允许上传大小上限:头像最大 4MB、文章视频最大 100MB,留出余量(默认 1MB 会拦截上传)
|
||||
client_max_body_size 110m;
|
||||
|
||||
# ---------- 安全响应头(防点击劫持 / MIME 嗅探 / 泄露来源页) ----------
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
# 说明:前端 HTML 内已有 CSP meta;如需更强防护(frame-ancestors 等),
|
||||
# 可在此追加:add_header Content-Security-Policy "default-src 'self'; ..." always;
|
||||
|
||||
# Let's Encrypt 证书签发校验目录(certbot --webroot 模式使用)
|
||||
# 需要先创建:sudo mkdir -p /var/www/blog/.well-known/acme-challenge
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/blog;
|
||||
}
|
||||
|
||||
# ---------- /myip 返回客户端公网 IP(SSH 白名单自动更新用) ----------
|
||||
# 家庭端脚本先请求此地址获取当前公网 IP,变化后再调用 /api/ipwatch/report
|
||||
# 注意:certbot 生成 443 的 server 块时,需要把这一段也复制进去
|
||||
location = /myip {
|
||||
default_type text/plain;
|
||||
return 200 $remote_addr;
|
||||
}
|
||||
|
||||
# ---------- /api/* 反向代理到 FastAPI ----------
|
||||
# FastAPI 只监听 127.0.0.1:8080,公网无法直连,必须走 Nginx 代理
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# WASM 预编译等长任务可能超过 60s,放宽到 180s
|
||||
proxy_read_timeout 180s;
|
||||
}
|
||||
|
||||
# 说明:服务器 /etc/nginx/mime.types 需追加一行 application/wasm wasm;(已执行)
|
||||
# ---------- /uploads/* 静态文件 ----------
|
||||
# 头像、文章图片、项目压缩包由 Nginx 直接提供,不经过 Python
|
||||
location /uploads/ {
|
||||
alias /var/www/blog/uploads/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
# ---------- /demo/* 在线演示(L0 静态托管) ----------
|
||||
# 上传的项目 zip 若为纯 HTML/CSS/JS,后端解压到 uploads/demos/{项目id}/,
|
||||
# 此处由 Nginx 直接提供(不经过 Python,禁止执行)
|
||||
location /demo/ {
|
||||
alias /var/www/blog/uploads/demos/;
|
||||
index index.html;
|
||||
try_files $uri $uri/ =404;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
# ---------- 前端静态文件(SPA) ----------
|
||||
# root 必须指向 frontend 目录:页面里引用的是 /main.js /style.css 等绝对路径
|
||||
location / {
|
||||
root /var/www/blog/frontend;
|
||||
index index.html;
|
||||
# SPA 刷新支持:
|
||||
# 先找真实文件($uri),再找目录($uri/),
|
||||
# 都找不到就回退到 index.html,由前端 History API 路由接管
|
||||
try_files $uri $uri/ /index.html;
|
||||
# 每次重新校验,避免部署后浏览器继续用旧版 JS 导致页面渲染异常
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
}
|
||||
|
||||
# ============ HTTPS 443:用 certbot 一键开启 ============
|
||||
# 推荐方式(自动改写本配置文件并管理续期):
|
||||
# sudo apt install certbot python3-certbot-nginx
|
||||
# sudo certbot --nginx -d your-domain.com
|
||||
# 执行成功后,本文件会被自动加入 443 监听与证书路径,无需手写。
|
||||
# 若要手写,参考下面的骨架(把上面 /api/、/uploads/、/ 三段复制进来即可):
|
||||
# server {
|
||||
# listen 443 ssl;
|
||||
# listen [::]:443 ssl;
|
||||
# server_name your-domain.com;
|
||||
# ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# # ... 同上三个 location 块 ...
|
||||
# }
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# MyBlog 服务器端更新脚本(由本地 deploy/update.ps1 上传并执行)
|
||||
# 职责:备份 -> 解压新代码 -> 数据库迁移 -> 重启服务 -> 验证
|
||||
# 用法:bash /tmp/update_server.sh [域名]
|
||||
# 适用:Ubuntu + systemd(myblog 服务);需要 root 或已配置免密 sudo
|
||||
# 安全说明:
|
||||
# - 更新前自动备份到 /root/blog-backups(含数据库与上传文件),可随时回滚
|
||||
# - 只增量覆盖代码文件,不删除服务器上的任何文件(不会加 --delete)
|
||||
# - 数据库迁移是幂等的:重复执行只会补齐缺失的列,不会损坏数据
|
||||
# ============================================================
|
||||
set -eu
|
||||
# bash 支持 pipefail(dash/sh 下自动跳过),保证失败即退出
|
||||
if [ -n "${BASH_VERSION:-}" ]; then set -o pipefail; fi
|
||||
|
||||
BLOG_DIR="/var/www/blog"
|
||||
PKG="/tmp/myblog.tar.gz"
|
||||
BACKUP_DIR="/root/blog-backups"
|
||||
DOMAIN="${1:-}"
|
||||
|
||||
# 非 root 用户运行时自动加 sudo(前提:已配置免密 sudo)
|
||||
SUDO=""
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
echo "== [1/5] 更新前备份 =="
|
||||
$SUDO mkdir -p "$BACKUP_DIR"
|
||||
TS=$(date +%Y%m%d_%H%M%S)
|
||||
$SUDO tar -czf "$BACKUP_DIR/blog_backup_$TS.tar.gz" --exclude=venv --exclude='*/__pycache__' -C "$BLOG_DIR" .
|
||||
echo "备份完成:$BACKUP_DIR/blog_backup_$TS.tar.gz"
|
||||
|
||||
echo "== [2/5] 解压新代码 =="
|
||||
if [ ! -f "$PKG" ]; then
|
||||
echo "错误:找不到更新包 $PKG,请先通过本地脚本上传" >&2
|
||||
exit 1
|
||||
fi
|
||||
$SUDO tar -xzf "$PKG" -C "$BLOG_DIR"
|
||||
$SUDO chown -R blog:blog "$BLOG_DIR"
|
||||
# 前端缓存指纹:每次部署自动生成新版本号,浏览器强制拉取最新 JS/CSS,无需手动 Ctrl+F5
|
||||
VERSION=$(date +%Y%m%d%H%M%S)
|
||||
$SUDO sed -i "s/__VERSION__/${VERSION}/g" "$BLOG_DIR/frontend/index.html"
|
||||
echo "前端版本号:${VERSION}"
|
||||
|
||||
# 把 Windows 上传的脚本统一转成 LF 行尾,防止 bash 解析 CR 报错
|
||||
find "$BLOG_DIR/deploy" -maxdepth 2 -name '*.sh' -type f -exec sed -i 's/\r$//' {} +
|
||||
echo "解压完成,文件属主已修正为 blog:blog"
|
||||
|
||||
echo "== [3/5] 数据库迁移(幂等) =="
|
||||
python3 - "$BLOG_DIR" <<'PY'
|
||||
import sqlite3, sys
|
||||
from pathlib import Path
|
||||
base = Path(sys.argv[1])
|
||||
db = base / "backend" / "blog.db"
|
||||
# 读取 .env 的 DATABASE_URL(与后端 database.py 的解析方式一致,留空用默认)
|
||||
try:
|
||||
for line in (base / ".env").read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("DATABASE_URL="):
|
||||
val = line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
if val.startswith("sqlite:///"):
|
||||
p = val[len("sqlite:///"):]
|
||||
db = Path(p) if p.startswith("/") else base / p
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
print("目标数据库:", db)
|
||||
con = sqlite3.connect(db)
|
||||
cur = con.cursor()
|
||||
def ensure_table(table, ddl):
|
||||
rows = cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchall()
|
||||
if not rows:
|
||||
cur.execute(ddl)
|
||||
print(f"建表:{table}")
|
||||
else:
|
||||
print(f"已存在:{table}")
|
||||
|
||||
def ensure_column(table, col, ddl):
|
||||
cols = [r[1] for r in cur.execute(f"PRAGMA table_info({table})").fetchall()]
|
||||
if col not in cols:
|
||||
cur.execute(f"ALTER TABLE {table} ADD COLUMN {ddl}")
|
||||
print(f"迁移:{table}.{col} 已新增")
|
||||
else:
|
||||
print(f"已存在:{table}.{col}")
|
||||
ensure_column("articles", "category", "category VARCHAR(30) NOT NULL DEFAULT 'life'")
|
||||
ensure_column("users", "token_version", "token_version INTEGER NOT NULL DEFAULT 0")
|
||||
ensure_column("users", "avatar_updated_time", "avatar_updated_time DATETIME")
|
||||
ensure_column("friends", "created_time", "created_time DATETIME")
|
||||
ensure_table("projects", """
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
tech VARCHAR(500),
|
||||
project_type VARCHAR(20) NOT NULL DEFAULT 'static',
|
||||
demo_url VARCHAR(500),
|
||||
download_url VARCHAR(500),
|
||||
github_url VARCHAR(500),
|
||||
created_time DATETIME NOT NULL,
|
||||
updated_time DATETIME NOT NULL
|
||||
)""")
|
||||
ensure_column("projects", "visibility", "visibility VARCHAR(20) NOT NULL DEFAULT 'public'")
|
||||
con.commit()
|
||||
con.close()
|
||||
print("数据库迁移完成")
|
||||
PY
|
||||
|
||||
echo "== [4/5] 重启后端服务 =="
|
||||
$SUDO systemctl restart myblog
|
||||
sleep 3
|
||||
if ! $SUDO systemctl is-active --quiet myblog; then
|
||||
echo "错误:myblog 服务未正常运行,请查看日志:journalctl -u myblog -n 50" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "myblog 服务运行正常"
|
||||
|
||||
echo "== [5/5] 验证 =="
|
||||
echo "--- 后端接口(应返回 success:true) ---"
|
||||
curl -s http://127.0.0.1:8080/api/article/list | head -c 200
|
||||
echo
|
||||
echo "--- 新前端是否生效(发布管理弹窗标记 pub-confirm 数量) ---"
|
||||
grep -c "pub-confirm" "$BLOG_DIR/frontend/manage.js" || true
|
||||
if [ -n "$DOMAIN" ]; then
|
||||
echo "--- 线上 HTTPS($DOMAIN) ---"
|
||||
curl -sk -o /dev/null -w "%{http_code}\n" "https://$DOMAIN/" || echo "HTTPS 检查失败(可能是证书或网络问题)"
|
||||
fi
|
||||
echo "更新完成。如需回滚:备份在 $BACKUP_DIR/"
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 = "注册";
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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, ``);
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/* =====================================================================
|
||||
* Markdown 轻量渲染器(markdown.js)
|
||||
* ---------------------------------------------------------------------
|
||||
* 【这个文件是干什么的?】
|
||||
* 博客文章正文是用 Markdown 语法写的(# 标题、**加粗**、```代码块```),
|
||||
* 但浏览器只认 HTML。这个文件负责把 Markdown 文本"翻译"成 HTML 字符串,
|
||||
* 再交给其他模块(article.js 渲染文章正文、manage.js 做编辑预览)插入页面。
|
||||
*
|
||||
* 【整体思路:分两层处理】
|
||||
* 第 1 层(安全):先把整篇文本用 escapeHtml 转义,
|
||||
* 把 < > & 等特殊字符变成 < > & 这样的"HTML 实体"。
|
||||
* 这样用户写的内容永远不会被当成 HTML 标签执行 —— 这是防 XSS 攻击的关键。
|
||||
* 第 2 层(排版):把转义后的文本按"块级元素"逐行解析
|
||||
* (标题 / 段落 / 列表 / 引用 / 分隔线 / 代码块),
|
||||
* 每一行内部再调用 inlineMarkdown 处理"行内元素"(加粗 / 斜体 / 链接等)。
|
||||
*
|
||||
* 【两个函数的分工】
|
||||
* - inlineMarkdown(text):只管"一行文本内部"的语法替换(行内元素)
|
||||
* - renderMarkdown(md):入口函数,负责整篇的分行、分块、组装(块级元素)
|
||||
* ===================================================================== */
|
||||
|
||||
import { escapeHtml, safeUrl } from "./utils.js";
|
||||
|
||||
/**
|
||||
* 行内 Markdown 渲染
|
||||
* 只处理"行内"语法(不跨行),比如:
|
||||
* **加粗** *斜体* `行内代码` [链接文字](URL) 
|
||||
* 原理:用一串正则表达式依次"查找 - 替换",把 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>
|
||||
// 为什么匹配 > 而不是 > ?
|
||||
// 因为整篇已经过 escapeHtml 转义,> 已经变成了 >
|
||||
const quote = line.match(/^>\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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/* =====================================================================
|
||||
* 全局常量与状态(state.js)
|
||||
* 各模块通过 import 共享;不包含任何 DOM 操作。
|
||||
* ===================================================================== */
|
||||
|
||||
// 导航分区:life 生活区 / projects 项目区 / study 学习区(简介已并入首页,不再单独成区)
|
||||
// 文章分区只有 life / study;projects 是独立的项目专区
|
||||
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",
|
||||
};
|
||||
@@ -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; }
|
||||
@@ -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) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[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 };
|
||||
}
|
||||
Binary file not shown.
@@ -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
|
||||
Reference in New Issue
Block a user