Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a5a7fa929 | ||
|
|
7e6dd0cc02 | ||
|
|
f39f0f4320 | ||
|
|
de27e53287 | ||
|
|
52ca450ddd | ||
|
|
4c32267b47 | ||
|
|
2e3888d5b1 | ||
|
|
0f1dc7969d | ||
|
|
443eccb8a6 | ||
|
|
7d12167380 | ||
|
|
7f0beb5b0c | ||
|
|
76b744d5b5 | ||
|
|
efc30156b9 | ||
|
|
856d414295 | ||
|
|
5b227b71ef | ||
|
|
a0061b14e3 | ||
|
|
923d57475b | ||
|
|
7062c755a3 | ||
|
|
2c46846309 | ||
|
|
b1e9d9cae6 | ||
|
|
5afc7fa9be | ||
|
|
f307781f58 | ||
|
|
fed6bf9570 | ||
|
|
46d6f3cd94 | ||
|
|
c9f858b626 | ||
|
|
e314b05670 | ||
|
|
f7870e8557 | ||
|
|
38cd09f723 | ||
|
|
c3f8be58c9 | ||
|
|
c5218f35a1 | ||
|
|
9c0ba8cd6d | ||
|
|
bd3a875403 | ||
|
|
5f21c73044 |
@@ -15,11 +15,12 @@
|
||||
- **瀑布流布局** — 首页文章以瀑布流展示,支持无限滚动加载
|
||||
- **阅读统计** — 文章阅读量统计,带机器人流量检测
|
||||
- **附件上传** — 文章支持上传附件,基于内容寻址自动去重
|
||||
- **RSS 订阅** — 自动生成 RSS Feed(`/rss`、`/feed`)
|
||||
- **RSS 订阅** — 自动生成 RSS Feed(`/rss`、`/feed`),链接使用站点设置的规范地址
|
||||
- **搜索功能** — 文章全文搜索
|
||||
- **多语言** — 支持中文 / English,自动检测浏览器语言或手动切换
|
||||
- **自适应界面** — Tailwind CSS,桌面端和移动端均可正常使用
|
||||
- **开箱即用** — 首次运行自动创建配置文件、数据库和管理员账号
|
||||
- **安全加固** — 会话密钥加密随机、Cookie HttpOnly/SameSite=Lax/HTTPS Secure、全站 CSRF、SQL 注入防护(参数化+路由 ID 数值化)、`/uploads` 白名单挂载(SQLite 数据库不可下载)、登录限速(IP+用户名,5 次失败锁 15 分钟)、附件越权校验、上传安全(危险扩展黑名单+magic-bytes 检测+头像 JPEG 重编码)、Gravatar 默认关闭、禁用用户会话实时失效,完整清单见 [SECURITY_TODO.md](./SECURITY_TODO.md)
|
||||
- **开箱即用** — 首次运行自动创建配置文件、数据库;管理员账号密码为随机生成并仅一次性打印(不再使用 admin/admin)
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -31,7 +32,8 @@
|
||||
| Session | [gin-contrib/sessions](https://github.com/gin-contrib/sessions) |
|
||||
| 配置 | [gopkg.in/yaml.v3](https://gopkg.in/yaml.v3) |
|
||||
| 密码 | [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto/bcrypt) |
|
||||
| CSS | [Tailwind CSS](https://tailwindcss.com)(CDN) |
|
||||
| 文件校验 | [gabriel-vasile/mimetype](https://github.com/gabriel-vasile/mimetype) |
|
||||
| CSS | [Tailwind CSS](https://tailwindcss.com)(构建期静态生成,见 `scripts/build_tailwind.sh`) |
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -39,7 +41,11 @@
|
||||
go run .
|
||||
```
|
||||
|
||||
打开 <http://localhost:8080> ,使用 **admin** / **admin** 登录。
|
||||
打开 <http://localhost:8080> 登录。首次运行时会自动创建管理员账号 `admin`,初始密码为**随机生成**并仅一次性打印在日志中——请立即记录并登录后修改(SECURITY_TODO #12,不再使用默认 admin/admin)。
|
||||
|
||||
> 前端资源全部本地化(`static/css/app.css` 为 Tailwind 静态构建产物,已提交)。修改 HTML 模板/Go 代码中的 Tailwind 类后,运行 `./scripts/build_tailwind.sh`(需 Node ≥ 18,npx 可用)重新生成并提交新产物;vendor 库更新同理重新下载到 `static/vendor/` 并提交。
|
||||
|
||||
> 安全状态一览见下方 [安全加固](#安全加固) 章节,完整修复清单与验证记录见 [SECURITY_TODO.md](./SECURITY_TODO.md)。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -55,9 +61,14 @@ go run .
|
||||
database:
|
||||
type: sqlite # sqlite(默认)或 mysql
|
||||
dsn: "" # MySQL 连接串,sqlite 模式下忽略
|
||||
port: "8080" # Web 服务端口
|
||||
web:
|
||||
port: "8080" # Web 服务端口,"" 或 "0" 可只启用 socket
|
||||
socket: "" # unix socket 路径(Linux 部署推荐,见 install_linux.sh)
|
||||
trusted_proxies: # 可信反向代理 IP/CIDR;直接影响 X-Forwarded-For
|
||||
- 127.0.0.1 # 仅列表内的代理可设置客户端 IP(防 XFF 伪造)
|
||||
- ::1
|
||||
path: ./win/srv/blog_go # 数据存储路径(数据库、上传文件)
|
||||
secret: <自动生成> # Session 加密密钥
|
||||
secret: <自动生成> # Session 加密密钥;缺失时拒绝启动
|
||||
```
|
||||
|
||||
### 使用 MySQL
|
||||
@@ -68,7 +79,8 @@ secret: <自动生成> # Session 加密密钥
|
||||
database:
|
||||
type: mysql
|
||||
dsn: user:password@tcp(127.0.0.1:3306)/blog_go?charset=utf8mb4&parseTime=True&loc=Local
|
||||
port: "8080"
|
||||
web:
|
||||
port: "8080"
|
||||
```
|
||||
|
||||
先创建数据库:
|
||||
@@ -79,6 +91,19 @@ CREATE DATABASE blog_go CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
## 项目结构
|
||||
|
||||
| 目录 | 作用 |
|
||||
|---|---|
|
||||
| `config/` | 配置加载与自动创建(区分 Linux/Windows 路径) |
|
||||
| `models/` | 数据模型:用户、文章、标签、评论、附件、阅读统计、站点设置等 + GORM 初始化 |
|
||||
| `handlers/` | HTTP 请求处理:首页、文章、登录/注册、评论、后台管理、个人中心、RSS、上传校验 |
|
||||
| `middleware/` | 中间件:登录/角色鉴权、CSRF、HTTPS 检测、安全响应头(CSP/HSTS 等) |
|
||||
| `i18n/` | 中英文翻译与浏览器语言检测 |
|
||||
| `templates/` | HTML 模板:layouts 公共布局、pages 前台、admin 后台、user 用户文章 |
|
||||
| `static/` | 静态资源:Tailwind 产物、前端 Markdown 渲染器、vendor 本地化第三方库(marked、highlight.js 等) |
|
||||
| `scripts/` | 构建辅助脚本(Tailwind 重建、数据库迁移 SQL) |
|
||||
|
||||
根目录文件:`main.go`(入口 + 路由注册)、`main_test.go` 与 `handlers/_test.go`(测试)、`fresh.conf`(fresh 热重载配置)、`install_linux.sh`(Linux 部署脚本)。逐文件说明:
|
||||
|
||||
```
|
||||
go_blog/
|
||||
├── main.go # 入口,路由注册,中间件装配
|
||||
@@ -101,12 +126,16 @@ go_blog/
|
||||
│ ├── tag.go # 标签模型
|
||||
│ └── upload_config.go # 上传配置
|
||||
├── middleware/
|
||||
│ └── auth.go # 登录验证、角色鉴权、语言检测
|
||||
│ ├── auth.go # 登录验证、角色鉴权、语言检测
|
||||
│ ├── csrf.go # CSRF 同步器令牌防护
|
||||
│ ├── https.go # 请求是否 HTTPS 检测(cookie Secure)
|
||||
│ └── security_headers.go # 安全响应头(CSP、HSTS、nosniff 等)
|
||||
├── handlers/
|
||||
│ ├── helpers.go # 公共工具函数
|
||||
│ ├── home.go # 首页
|
||||
│ ├── article.go # 文章详情、文章列表 API
|
||||
│ ├── auth.go # 登录
|
||||
│ ├── login_ratelimit.go # 登录限速(IP+用户名,5 次失败锁定 15 分钟)
|
||||
│ ├── admin.go # 管理后台首页
|
||||
│ ├── admin_comment.go # 评论管理
|
||||
│ ├── admin_user.go # 用户管理
|
||||
@@ -117,9 +146,17 @@ go_blog/
|
||||
│ ├── profile.go # 个人中心
|
||||
│ ├── rss.go # RSS Feed
|
||||
│ ├── settings.go # 站点/导航/上传/评论设置
|
||||
│ └── upload_validator.go # 上传文件校验
|
||||
│ └── upload_validator.go # 上传文件校验(扩展名白名单 + magic-bytes)
|
||||
├── i18n/
|
||||
│ └── i18n.go # 中英文翻译映射 + Accept-Language 检测
|
||||
├── static/
|
||||
│ ├── css/app.css # Tailwind 静态构建产物(go:embed 编入二进制)
|
||||
│ ├── css/input.css # Tailwind 构建输入(@tailwind 指令)
|
||||
│ ├── css/markdown.css # Markdown 排版样式(go:embed 编入二进制)
|
||||
│ ├── js/markdown.js # 前端 Markdown 渲染器(BlogMD)
|
||||
│ └── vendor/ # 本地化的第三方前端库(marked/DOMPurify/highlight.js/cropperjs/easymde)
|
||||
├── scripts/
|
||||
│ └── build_tailwind.sh # 重新生成 Tailwind CSS(需 Node,见下)
|
||||
├── templates/
|
||||
│ ├── layouts/base.html # 公共布局(导航栏 + 头像下拉菜单 + 页脚)
|
||||
│ ├── pages/
|
||||
@@ -151,9 +188,34 @@ go_blog/
|
||||
│ └── config.yaml
|
||||
└── srv/blog_go/
|
||||
├── blog.db
|
||||
└── avatars/
|
||||
├── attachments/
|
||||
├── avatars/
|
||||
└── logos/
|
||||
```
|
||||
|
||||
## 安全加固
|
||||
|
||||
基于 2026-08-19 安全审计与网络上线验证(共 25 项发现,P0–P3 均已修复,详见 [SECURITY_TODO.md](./SECURITY_TODO.md)):
|
||||
|
||||
- **认证与会话**
|
||||
- 会话密钥由 `crypto/rand` 生成 32 字节随机数;配置文件缺失 secret 时拒绝启动(不再静默回退)
|
||||
- Cookie 加固:`HttpOnly` + `SameSite=Lax`,HTTPS 下自动加 `Secure`
|
||||
- 全站 CSRF 防护(同步器令牌,30+ 表单与 AJAX 全覆盖);登录/注册成功强制会话轮换(防会话固定)
|
||||
- 登录限速:按 IP+用户名 5 次失败锁定 15 分钟;用户不存在时也执行 bcrypt 比较,抹平计时侧信道
|
||||
- 禁用/锁定/软删用户的会话实时失效;管理员口令 bcrypt cost 12
|
||||
- **数据与注入**
|
||||
- GORM 全参数化查询;管理路由的 `:id` 先解析为数值再入查询(防字符串条件注入)
|
||||
- `/uploads` 仅白名单挂载子目录,SQLite 数据库文件不可从公网下载;目录列表与路径穿越一律 404
|
||||
- **上传安全**
|
||||
- 扩展名白名单 + 危险扩展名黑名单(.html/.svg/.js 等,防同源 Active Content)
|
||||
- 内容 magic-bytes 与声明类型一致性校验(gabriel-vasile/mimetype)
|
||||
- 头像强制解码→256px 缩放→JPEG 重编码后落盘,原始字节一律不落地
|
||||
- **输出与传输**
|
||||
- CSP(`default-src 'self'`,第三方前端资源已本地化)、`X-Frame-Options: DENY`、`X-Content-Type-Options: nosniff`、`Referrer-Policy`、`Permissions-Policy`;HTTPS 下发 HSTS
|
||||
- 客户端 IP 解析仅信任 `web.trusted_proxies` 名单内代理(防 X-Forwarded-For 伪造);RSS 链接使用站点设置的规范地址(防 Host 头污染)
|
||||
|
||||
安全回归用例 50+(`handlers/*_test.go`、`main_test.go`、`middleware/*_test.go`),`go test -race ./...` 全绿。
|
||||
|
||||
## 路由
|
||||
|
||||
### 公开路由
|
||||
@@ -162,57 +224,71 @@ go_blog/
|
||||
|---|---|---|
|
||||
| GET | `/` | 首页 |
|
||||
| GET | `/search` | 搜索页 |
|
||||
| GET | `/api/articles` | 文章列表 API(无限滚动) |
|
||||
| GET | `/rss`、`/feed` | RSS 订阅 |
|
||||
| GET | `/article/:slug` | 文章详情 |
|
||||
| POST | `/article/:slug/comments` | 发表评论 |
|
||||
| GET | `/rss`、`/feed` | RSS 订阅 |
|
||||
| GET | `/login` | 登录页 |
|
||||
| POST | `/login` | 提交登录 |
|
||||
| GET | `/register` | 注册页 |
|
||||
| POST | `/register` | 提交注册 |
|
||||
| POST | `/logout` | 退出登录 |
|
||||
| GET | `/uploads/*` | 静态文件(头像、附件等) |
|
||||
|
||||
### 公开 JSON API(`/api` 前缀)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/articles` | 文章列表 API(无限滚动) |
|
||||
| POST | `/api/auth/login` | 登录(限流锁定 429) |
|
||||
| POST | `/api/auth/register` | 注册(用户名冲突 409) |
|
||||
| POST | `/api/auth/logout` | 退出登录 |
|
||||
| POST | `/api/article/:slug/comments` | 发表评论(校验错误 400) |
|
||||
|
||||
### 管理后台(需管理员权限)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/admin` | 管理后台首页 |
|
||||
| GET | `/admin/articles` | 文章列表 |
|
||||
| GET/POST | `/admin/articles/new` | 新建文章 |
|
||||
| GET/POST | `/admin/articles/:id/edit` | 编辑文章 |
|
||||
| POST | `/admin/articles/:id/delete` | 删除文章 |
|
||||
| POST | `/admin/articles/attachments` | 上传附件 |
|
||||
| POST | `/admin/articles/attachments/:id/delete` | 删除附件 |
|
||||
| GET | `/admin/articles/:id/attachments` | 附件列表 |
|
||||
| GET | `/admin/articles/new` | 新建文章表单 |
|
||||
| GET | `/admin/articles/:id/edit` | 编辑文章表单 |
|
||||
| GET | `/admin/comments` | 评论管理 |
|
||||
| POST | `/admin/comments/:id/approve` | 通过评论 |
|
||||
| POST | `/admin/comments/:id/reject` | 拒绝评论 |
|
||||
| POST | `/admin/comments/:id/delete` | 删除评论 |
|
||||
| GET | `/admin/users` | 用户列表 |
|
||||
| GET/POST | `/admin/users/new` | 新建用户 |
|
||||
| GET/POST | `/admin/users/:id/edit` | 编辑用户 |
|
||||
| POST | `/admin/users/:id/delete` | 删除用户 |
|
||||
| GET | `/admin/users/new` | 新建用户表单 |
|
||||
| GET | `/admin/users/:id/edit` | 编辑用户表单 |
|
||||
| GET | `/admin/analytics/views` | 阅读统计 |
|
||||
| GET/POST | `/admin/settings/site` | 站点设置 |
|
||||
| GET/POST | `/admin/settings/navlinks` | 导航链接设置 |
|
||||
| GET/POST | `/admin/settings/upload` | 上传设置 |
|
||||
| GET/POST | `/admin/settings/download` | 下载设置 |
|
||||
| GET/POST | `/admin/settings/comments` | 评论设置 |
|
||||
| GET | `/admin/settings/site`、`/navlinks`、`/upload`、`/download`、`/comments` | 各设置页 |
|
||||
|
||||
管理 JSON API(仅管理员,均带 JSON 错误码 + HTTP 语义状态码):
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| POST | `/api/admin/articles` | 创建文章 |
|
||||
| PUT/DELETE | `/api/admin/articles/:id` | 更新/删除文章 |
|
||||
| POST | `/api/admin/articles/attachments` | 上传附件(multipart) |
|
||||
| DELETE | `/api/admin/articles/attachments/:id` | 删除附件 |
|
||||
| GET | `/api/admin/articles/:id/attachments` | 附件列表 |
|
||||
| POST | `/api/admin/comments/:id/approve`、`/:id/reject`、`/:id/delete` | 评论审核操作 |
|
||||
| POST/PUT/DELETE | `/api/admin/users`、`/:id` | 用户增改删(自禁/最后管理员均 403) |
|
||||
| POST | `/api/admin/settings/site` | 站点设置(文本/URL/清除) |
|
||||
| POST | `/api/admin/settings/site/favicon`、`/site/logo` | favicon/logo 上传(multipart) |
|
||||
| POST | `/api/admin/settings/navlinks`、`/upload`、`/download`、`/comments` | 各设置保存(`action` 分发) |
|
||||
|
||||
### 个人中心(需登录)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET/POST | `/profile` | 编辑个人信息 |
|
||||
| POST | `/profile/avatar` | 上传头像 |
|
||||
| GET | `/profile` | 编辑个人信息页 |
|
||||
| POST | `/api/profile` | 保存资料(JSON,密码改密校验 400) |
|
||||
| POST | `/api/profile/avatar` | 上传头像(multipart、JPEG 重编码) |
|
||||
| GET | `/my/articles` | 我的文章列表 |
|
||||
| GET/POST | `/my/articles/new` | 新建文章 |
|
||||
| GET/POST | `/my/articles/:id/edit` | 编辑文章 |
|
||||
| POST | `/my/articles/:id/delete` | 删除文章 |
|
||||
| POST | `/my/articles/attachments` | 上传附件 |
|
||||
| POST | `/my/articles/attachments/:id/delete` | 删除附件 |
|
||||
| GET | `/my/articles/:id/attachments` | 附件列表 |
|
||||
| GET | `/my/articles/new`、`/:id/edit` | 我的文章表单页 |
|
||||
| POST/PUT/DELETE | `/api/my/articles`、`/:id` | 文章增改删(跨作者 404/403) |
|
||||
| POST | `/api/my/articles/attachments` | 上传附件(multipart) |
|
||||
| DELETE | `/api/my/articles/attachments/:id` | 删除附件 |
|
||||
| GET | `/api/my/articles/:id/attachments` | 附件列表 |
|
||||
|
||||
### API 约定
|
||||
|
||||
- 成功:`{"ok": true, "redirect": "...", "data": {...}}`(`redirect` 为原 302 目标,前端 fetch 后跳转)
|
||||
- 失败:`{"ok": false, "code": "<i18n 键>", "error": "<按请求语言翻译的文案>"}`;状态码:400 校验失败 / 401 未登录 / 403 无权限 / 404 不存在 / 409 冲突 / 429 限流锁定 / 500 服务错误
|
||||
- 除文件上传(multipart)外请求体为 `application/json`;CSRF 通过 `X-CSRF-Token` 请求头传递
|
||||
|
||||
## 用户角色
|
||||
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
# 安全修复 TODO
|
||||
|
||||
基于 2026-08-19 的安全审计(源码 + haibara.ai 线上验证)整理。
|
||||
2026-08-27 复审新增 #18–#25(标题标注"复审新发现")。
|
||||
2026-08-27 API 化(/api JSON 接口化,b1e9d9c..76b744d)后复审新增 #26–#32(标题标注"API 化复审新发现")。
|
||||
按优先级排序,完成后勾选并标注日期。
|
||||
|
||||
---
|
||||
|
||||
## P0 — 立即修复
|
||||
|
||||
### [x] 1. 会话密钥弱回退(可伪造管理员会话)✅ 2026-08-19
|
||||
- **位置**: `config/config.go`(`generateSecret` / `applyDefaults` 回退)
|
||||
- **问题**: secret 缺失时回退为 SHA-256(主机名+PID),两者均可被外部推测/爆破,攻击者可离线伪造任意用户会话 cookie。
|
||||
- **修复**:
|
||||
- [x] `generateSecret()` 改用 `crypto/rand` 生成 32 字节随机数
|
||||
- [x] 已有配置加载路径中 secret 为空时:拒绝启动(`log.Fatalf`),不再静默回退;配置文件读取失败也改为直接退出
|
||||
- [x] 首次生成配置文件时写入强随机 secret(保持 `install_linux.sh` 的 openssl 路径不变)
|
||||
- **验证**: ✅ 新密钥为 crypto/rand 输出;缺失 secret 时启动直接报错
|
||||
|
||||
### [x] 2. 全站无 CSRF 防护 ✅ 2026-08-19
|
||||
- **位置**: 全部 POST 路由(登录/注册/文章/评论/管理后台/设置/附件)
|
||||
- **问题**: 仅靠 cookie 认证,无 CSRF token;线上 cookie 无 SameSite 属性,浏览器默认 Lax 保护不完整(Chrome Lax+POST 豁免、Safari 差异)。
|
||||
- **修复**:
|
||||
- [x] 新增 `middleware/csrf.go`:同步器令牌模式(session 存储、常量时间比较),表单 `_csrf` 字段或 `X-CSRF-Token` 头二选一,不匹配返回 403
|
||||
- [x] 覆盖全部 30 个 POST 表单(含游客评论表单);AJAX(附件上传/删除、头像上传)经 `<meta name="csrf-token">` 下发 token 并以请求头携带
|
||||
- [x] `/article/:slug/comments` 游客 POST 一并覆盖(游客同样有 session)
|
||||
- **验证**: ✅ `middleware/csrf_test.go` 7 用例 + 端到端 curl 冒烟(无 token/伪造 token 403,有效 token 302)
|
||||
|
||||
### [x] 3. 附件接口越权(IDOR)✅ 2026-08-19
|
||||
- **位置**: `handlers/attachment.go`(DeleteAttachment / ListAttachments / UploadAttachment 的 article_id)
|
||||
- **问题**: `/my/articles/attachments/*` 仅要求登录,无所有权校验;任意登录用户可删除/列出全站任意附件、向他人文章挂附件。
|
||||
- **修复**:
|
||||
- [x] `DeleteAttachment`:admin / 上传者 / 所属文章作者三者之一,否则 403
|
||||
- [x] `ListAttachments`:文章作者或 admin,否则 403
|
||||
- [x] `UploadAttachment`:`article_id != 0` 时校验文章归属(admin 除外),否则 403
|
||||
- [x] 单元测试(`handlers/security_test.go`:越权 403 / 本人 200 / admin 覆盖)
|
||||
- **验证**: ✅ 普通用户 A 删除用户 B 的附件 -> 403(测试覆盖)
|
||||
|
||||
### [x] 4. 会话固定(Session Fixation)✅ 2026-08-19
|
||||
- **位置**: `handlers/auth.go`(Login / Register 自动登录)
|
||||
- **问题**: 登录成功后未清空旧 session,直接写入 user_id,固定攻击可劫持登录后会话。
|
||||
- **修复**:
|
||||
- [x] 认证成功后先 `session.Clear()` 再写入 `user_id`/`username` 并 Save;保留 lang 与 csrf_token(避免多标签页已渲染表单失效)
|
||||
- **验证**: ✅ 登录前后 cookie 值不同,旧 cookie 无法访问受保护路由(`TestLoginRotatesSession`)
|
||||
|
||||
### [x] 18. SQLite 数据库文件可被公开下载(全库泄露)(2026-08-27 复审新发现)✅ 2026-08-27
|
||||
- **位置**: `main.go:72`(`router.Static("/uploads", cfg.Path)` 把存储根整体挂载为静态目录)、`models/db.go:36`(`blog.db` 就放在该目录下)
|
||||
- **问题**: 未认证即可 `GET /uploads/blog.db` 下载整库——含 bcrypt 密码哈希、用户邮箱、评论者 IP/邮箱、私密评论(IsPrivate)、浏览记录等。
|
||||
- **修复**(采用方案 B,白名单挂载):
|
||||
- [x] `main.go` 新增 `registerUploadRoutes`:只挂载 `attachments/`、`avatars/`、`logos/` 及当前配置的附件存储目录,存储根不再整体暴露
|
||||
- [x] 自定义 `serveUploadDir` handler:禁用目录列表(目录/`..`/`\` 一律 404),只服务具体文件
|
||||
- [x] `safeStorageDir`:storage_dir 含穿越/绝对路径/反斜杠时回退 `attachments`(为 #22 提供纵深防御)
|
||||
- [ ] (可选加固)方案 A:将数据库文件移出存储根,需迁移存量 blog.db,暂缓
|
||||
- **验证**: ✅ `main_test.go` 4 用例(blog.db 404 / 无目录列表 / 穿越失败 / 三目录正常服务、自定义 storage_dir、不安全回退、重名去重)
|
||||
|
||||
### [x] 19. GORM 字符串条件 SQL 注入(admin 用户管理 3 处)(2026-08-27 复审新发现)✅ 2026-08-27
|
||||
- **位置**: `handlers/admin_user.go:253、289、362`(`db.First(&user, id)`,id 为 `c.Param("id")` 字符串)
|
||||
- **问题**: GORM 对 `First(dest, s)`:s 为非数字字符串且无附加参数时按原始 SQL 条件拼入 WHERE(v1.31.1 `statement.go:293-303` 已核实)。`GET /admin/users/1 OR 1=1/edit` 即可注入;UserEditPage 为 GET 无 CSRF 拦截,可诱导已登录管理员点击链接触发盲注。
|
||||
- **修复**:
|
||||
- [x] UserEditPage / UserUpdate / UserDelete 三处:先 `uintFormID` 解析为数值,非数值(0)直接 302 回列表页;查询一律走主键参数化
|
||||
- [x] 表单 Action URL 由解析后的数值 ID 构造,不再回拼原始路由参数
|
||||
- **验证**: ✅ `TestAdminUserRoutesRejectNonNumericIDs`(5 组注入串 × GET/POST/DELETE 均被拒、数据零变更、合法 ID 不受影响);变异测试确认旧代码下注入用例失败("1 OR 1=1" 返回 200)
|
||||
|
||||
### [x] 26. JSON API 无请求体大小限制——未认证内存耗尽 DoS(2026-08-27 API 化复审新发现)✅ 2026-08-27
|
||||
- **位置**: `handlers/api.go`(bindJSON / ShouldBindJSON)、`main.go`(全链路无 `http.MaxBytesReader`)
|
||||
- **问题**: 全部 POST/PUT/DELETE 已迁入 /api 且以 JSON 提交,但没有任何请求体大小上限:`net/http` 默认不限 body,JSON 解码超长字符串 token 时按需整段分配内存。未认证端点 `/api/auth/login`、`/api/auth/register`、`/api/article/:slug/comments` 均可直接打击——CSRF 不构成障碍(先 GET 登录页/文章页即可取得合法 token 与会话 Cookie)。发送 GB 级 `"username":"AAAA…"` 可使单实例进程 OOM。
|
||||
- **说明**: 旧表单端点(ParseForm)同样无限制,属沿袭缺陷而非 API 化引入;但 API 化是补齐的时机。
|
||||
- **修复**:
|
||||
- [x] 新增 `middleware/bodylimit.go` BodyLimit 中间件:非 multipart 请求统一 4 MiB(覆盖文章 Markdown 正文上限);multipart 按平台上传策略派生(启用的类型限制与全局默认取最大值,再 +1 MiB 编码开销)。Content-Length 已知且超限时读体前直接 413;其余经 `http.MaxBytesReader` 封装,读超限即截断
|
||||
- [x] `handlers/api.go` bindJSON 识别 `*http.MaxBytesError` 返回 413 + `request_too_large`(i18n 中英新增)
|
||||
- [x] 中间件顺序调整为 sessions → 会话加固 → SetUserContext → **BodyLimit** → CSRF:必须在 CSRF 之前(CSRF 解析 multipart 会读取整个请求体);SetUserContext 提前使 413 文案可按请求语言翻译
|
||||
- [x] 测试环境中间件链同步(security_test.go)
|
||||
- **验证**: ✅ `TestBodyLimitRejectsOversizedJSON`(已知长度 / chunked 流式两种形态的 4MB+ 登录请求 → 413/request_too_large)、`TestBodyLimitAllowsNormalJSON`(正常体放行至认证层 401)、`TestBodyLimitRejectsOversizedMultipart`(3MB multipart 被拒且附件表零写入)、`TestBodyLimitGETBypass`(GET 不受影响);`go build/vet/test -race ./...` 全绿
|
||||
|
||||
---
|
||||
|
||||
## P1 — 近期修复
|
||||
|
||||
### [x] 5. Cookie 缺 Secure / SameSite 标志 ✅ 2026-08-19
|
||||
- **位置**: `main.go`(session store)、`handlers/comment.go:82`(comment_uid)
|
||||
- **修复**:
|
||||
- [x] store 默认 `SameSite: Lax`;`Secure` 按请求动态设置(`middleware/https.go` 检测 TLS 或 X-Forwarded-Proto),通过中间件在每次请求时应用到 session cookie
|
||||
- [x] `comment_uid` 游客 cookie 同步补齐 `SameSite=Lax` + HTTPS 下 `Secure`
|
||||
- **验证**: ✅ 模拟 HTTPS 请求响应头 `Set-Cookie: ... HttpOnly; Secure; SameSite=Lax`;冒烟测试通过
|
||||
|
||||
### [x] 6. 缺失安全响应头 ✅ 2026-08-19
|
||||
- **位置**: 新增 `middleware/security_headers.go`(全局第一个注册)
|
||||
- **修复**:
|
||||
- [x] `Content-Security-Policy`(default-src 'self' + 现有 CDN 白名单 + frame-ancestors 'none' 等)
|
||||
- [x] `X-Content-Type-Options: nosniff`、`X-Frame-Options: DENY`、`Referrer-Policy`、`Permissions-Policy`
|
||||
- [x] `Strict-Transport-Security`(仅 HTTPS 请求下发,未加 includeSubDomains 以免影响 HTTP 子域)
|
||||
- **说明**: CSP 含 `'unsafe-inline'`(模板内联 script/style 必需);待 P2-9 CDN 本地化后可进一步收紧
|
||||
- **验证**: ✅ `middleware/security_headers_test.go`(headers 存在性、HSTS 条件下发)+ 冒烟 curl 确认
|
||||
|
||||
### [x] 7. X-Forwarded-For 伪造(IP 审计/浏览量可刷)✅ 2026-08-19
|
||||
- **位置**: `handlers/helpers.go`(GetClientIP)、`config/config.go`(WebConfig.TrustedProxies)、`main.go`
|
||||
- **修复**:
|
||||
- [x] 删除手动解析 XFF 首值逻辑,`GetClientIP` 改为 `c.ClientIP()`
|
||||
- [x] `router.SetTrustedProxies(cfg.Web.TrustedProxies)`;新增 `web.trusted_proxies` 配置项(默认 `["127.0.0.1", "::1"]`,unix socket 部署自动信任)
|
||||
- [x] gin 内部 XFF 从右往左取第一个不可信 IP:直接客户端伪造的 XFF 被忽略
|
||||
- **验证**: ✅ `middleware/clientip_test.go`(直接连接带假 XFF 取真实 IP / 代理链取最右不可信条目)
|
||||
|
||||
### [x] 8. goroutine 数据竞争(use-after-return)✅ 2026-08-19
|
||||
- **位置**: `handlers/home.go`(ArticleDetail → recordArticleView)
|
||||
- **修复**:
|
||||
- [x] goroutine 启动前同步提取 userID / ip / UA 为局部变量,`recordArticleView` 不再触碰 gin.Context 与 session
|
||||
- **验证**: ✅ `go test -race ./...` 全绿
|
||||
|
||||
### [x] 20. 被禁用/锁定/删除用户的会话不失效(2026-08-27 复审新发现)✅ 2026-08-27
|
||||
- **位置**: `middleware/auth.go:16-27`(AuthRequired 只看 session 是否有 user_id,不回库校验)
|
||||
- **问题**: 登录时的状态检查(`handlers/auth.go:50`)只在登录瞬间生效。管理员禁用/锁定/软删用户后,其已持有的 cookie 在最长 24h 内仍完全可用:发评论自动 Approved、写文章、传附件;`SetUserContext` 对已软删用户仍置 `is_logged_in=true`。
|
||||
- **修复**:
|
||||
- [x] `AuthRequired(db)` 回库校验 `Status == StatusNormal` 且未软删,失败则清 session(保留 lang 与 csrf_token)并跳转 /login;session user_id 一律先断言为数值再入 GORM(呼应 #19)
|
||||
- [x] SetUserContext:用户查询失败或非正常状态时 `is_logged_in` 置 false(评论自动通过随之失效,回落游客审核策略)
|
||||
- **验证**: ✅ `TestDisabledUserSessionInvalidated`(disabled/locked 旧 cookie → 302 /login)、`TestSoftDeletedUserSessionInvalidated`、`TestDisabledUserCommentsRequireApproval`(锁定后评论转 pending)
|
||||
|
||||
### [x] 21. 头像上传缺类别校验 + 可添加任意扩展名 → 存储型 XSS 链(2026-08-27 复审新发现)✅ 2026-08-27
|
||||
- **位置**: `handlers/profile.go:196-208`(UploadAvatar 未限制 image 类别)、`handlers/profile.go:223-226`(processAvatar 失败回退存原始字节)、`handlers/profile.go:96-107`(UpdateProfile 头像分支同样无类别校验、原样落盘)、`handlers/settings.go:269-290`(addUploadFileType 无危险扩展黑名单)
|
||||
- **问题**: logo/favicon 上传要求 `Category == image`(settings.go:113/153),但头像上传只查扩展名白名单且解码失败仍存原始文件;管理员又可在上传设置里添加任意扩展名(含 `.html`/`.svg`)。组合链:添加 `.html` 类型 → 任意登录用户以头像名义上传 HTML → 落在 `/uploads/avatars/` 同源可执行(CSP `script-src 'self' 'unsafe-inline'` 放行)。
|
||||
- **修复**:
|
||||
- [x] UploadAvatar / UpdateProfile 头像分支强制 `check.Type.Category == models.CategoryImage`,解码失败直接拒绝(不再回退存原始字节)
|
||||
- [x] UpdateProfile 头像同样经 processAvatar 解码→缩放→JPEG 重编码,原始字节不再落盘
|
||||
- [x] addUploadFileType 增加危险扩展黑名单(.html/.htm/.xhtml/.xht/.svg/.xml/.js/.mjs),拒绝添加并提示(settings_upload 页新增错误提示 + i18n)
|
||||
- [x] 附带修复:processAvatar 依赖的 png/gif 解码器此前未注册(旧代码靠"失败回退"掩盖),补 blank import
|
||||
- **验证**: ✅ `TestAddUploadFileTypeRejectsDangerousExtensions`(6 组危险扩展拒绝 + .md 正常)、`TestUploadAvatarRejectsNonImage`(.html 拒绝 / 图片扩展名包 HTML 拒绝 / 正常 PNG 转存 .jpg)、`TestUpdateProfileAvatarRejectsNonImage`(表单头像同样拒绝 + 正常图片成功)
|
||||
|
||||
### [x] 27. /api/auth/register 无速率限制——批量注册垃圾账户(2026-08-27 API 化复审新发现)✅ 2026-08-27
|
||||
- **位置**: `handlers/auth.go`(Register)、`handlers/login_ratelimit.go`
|
||||
- **问题**: 登录有限流器(#10,IP+用户名),注册完全没有。`allow_registration` 开启时机器人可无限批量注册用户;用户名唯一性检查与 bcrypt cost 12 都不构成成本屏障(注册不触发任何限流计数)。
|
||||
- **修复**:
|
||||
- [x] 新增 `handlers/rate_limit.go` `WindowRateLimiter`:固定窗口计数限流器(与 #10 相同的有界 map + 淘汰策略,键前缀区分 login/register)
|
||||
- [x] Register 按 IP 限流注册(10 次/小时/IP),超限 429 + i18n 新增 `register_locked`(中英)
|
||||
- **验证**: ✅ `TestRegisterRateLimited`(同 IP 连续 10 次成功、第 11 次 429/register_locked、其他 IP 不受影响)、`TestWindowLimiterFixedWindow`(窗口内超限拒绝 / 窗口翻转重置 / 键隔离)
|
||||
|
||||
### [x] 28. 评论提交无速率限制(2026-08-27 API 化复审新发现)✅ 2026-08-27
|
||||
- **位置**: `handlers/comment.go`(PostComment)、`models/seed.go`(默认策略)
|
||||
- **问题**: 未认证即可提交评论(默认 `AllowGuest=true`),且无任何频率限制;配合默认 `GuestRequireApproval=false`(即时公开显示),开箱即用状态可被灌水机刷屏,同时放大 #26 的攻击面。
|
||||
- **修复**:
|
||||
- [x] PostComment 按 IP 限流评论提交(5 条/分钟/IP,复用 #27 的 `WindowRateLimiter`),超限 429 + i18n 新增 `comments_locked`(中英)
|
||||
- [ ] (可选,产品决策)新部署默认 `GuestRequireApproval=true`——留待产品确认,未随本项实施
|
||||
- **验证**: ✅ `TestCommentRateLimited`(同 IP 5 次成功、第 6 次 429/comments_locked、其他 IP 不受影响)
|
||||
|
||||
---
|
||||
|
||||
## P2 — 计划修复
|
||||
|
||||
### [x] 9. 第三方 CDN 无 SRI / Tailwind dev CDN ✅ 2026-08-27
|
||||
- **位置**: `templates/layouts/base.html:16-20`、`:118-122`、`middleware/security_headers.go`
|
||||
- **修复**:
|
||||
- [x] marked / DOMPurify / highlight.js / cropperjs / easymde 固定版本下载至 `static/vendor/`,经 go:embed 本地分发(easymde 自含 CodeMirror;拼写检查字典为可选外链,断网静默降级)
|
||||
- [x] `cdn.tailwindcss.com` 替换为构建期静态 CSS:`scripts/build_tailwind.sh`(tailwindcss 3.4.17,content 扫 templates+handlers+main.go 保证 Go 侧拼接类不漏),产物 `static/css/app.css` 提交仓库
|
||||
- [x] 配合 #6 收紧 CSP 为 `script-src 'self' 'unsafe-inline'`,移除全部 CDN 域名
|
||||
- **验证**: ✅ 断网第三方域名后页面渲染功能完整(puppeteer 冒烟:首页/编辑器资源 200、Tailwind 样式生效、无 JS 报错);CSP 已无第三方来源
|
||||
|
||||
### [x] 10. 登录无速率限制 ✅ 2026-08-27
|
||||
- **位置**: `handlers/auth.go:33`
|
||||
- **修复**:
|
||||
- [x] 新建 `handlers/login_ratelimit.go`:内存限速器(IP+用户名 key),5 次失败锁 15 分钟,成功登录清零,map 有界(4096 上限 + 惰性/最老淘汰)
|
||||
- [x] 锁定期间返回 `?error=locked` 明确提示(不泄露用户存在性);失败提示保持统一 `?error=1`
|
||||
- **验证**: ✅ `TestLoginRateLimited`(5 次失败→锁定→正确密码也被拒→Reset 恢复→其他用户不受影响)
|
||||
|
||||
### [x] 11. 配置文件权限过宽 ✅ 2026-08-27
|
||||
- **位置**: `config/config.go`
|
||||
- **修复**: `os.WriteFile(configFile, data, 0644)` → `0640`(secret 写入后不再组/世界可读;`install_linux.sh` 原有 0640 保持一致)
|
||||
- **验证**: ✅ `TestConfigFileCreatedNotWorldReadable`(创建后 perm == 0640)
|
||||
|
||||
### [x] 12. 首启弱凭据 admin/admin ✅ 2026-08-27(方案 A)
|
||||
- **位置**: `models/db.go`
|
||||
- **修复**(方案 A):
|
||||
- [x] 首启生成 16 位随机密码(crypto/rand,字母表排除易混淆字符),一次性打印日志;不再使用 admin/admin
|
||||
- **说明**: 线上已改密(已验证),此项为防御新部署
|
||||
- **验证**: ✅ `TestRandomAdminPassword`(长度/字符合法/两次生成不同)
|
||||
|
||||
### [x] 13. Unix socket 权限 666 ✅ 2026-08-27
|
||||
- **位置**: `install_linux.sh:80`
|
||||
- **修复**: `chmod 666` → `chown blog_go:blog_go + chmod 660`,安装结束打印提示:反向代理运行用户需 `usermod -aG blog_go <proxy_user>`
|
||||
- **说明**: 部署脚本改动,需在 Linux 环境验证(本机无法执行);本机任意用户已不能再绕过 Cloudflare 直连
|
||||
|
||||
### [x] 22. storage_dir 路径穿越 ✅ 2026-08-27
|
||||
- **位置**: `handlers/settings.go`(saveUploadConfig)、`handlers/attachment.go`、`main.go` safeStorageDir
|
||||
- **修复**:
|
||||
- [x] saveUploadConfig 校验存储目录为单个安全路径段(`^[A-Za-z0-9_-]+$`,手写 safeStorageDirName),非法直接拒绝并提示 `?error=illegal_dir`(i18n 新增)
|
||||
- [x] 保留 main.go `safeStorageDir` 运行时兜底作为纵深防御(不改)
|
||||
- **验证**: ✅ `TestStorageDirTraversalRejected`(6 组穿越值均拒绝且库中值不变 / 合法值正常保存)+ `TestSafeStorageDirNameAndValidators` 纯函数表驱动
|
||||
|
||||
### [x] 23. 密码策略缺失(改密/管理员建号无最小长度)✅ 2026-08-27
|
||||
- **位置**: `handlers/profile.go`(改密)、`handlers/admin_user.go` UserCreate/UserUpdate
|
||||
- **修复**: 新增公共 `validatePassword`(≥6 位,与注册口径一致),三处统一调用,失败回渲染表单/跳转 + i18n 提示(profile_password_short / user_password_short)
|
||||
- **验证**: ✅ `TestProfilePasswordMinLength`(1 位拒绝且旧哈希保留 / 6 位成功)、`TestAdminUserPasswordAndEmailEnforcement`(建号/重置短密码均拒绝)
|
||||
|
||||
### [x] 24. 邮箱字段不校验格式 ✅ 2026-08-27
|
||||
- **位置**: `handlers/auth.go`(注册)、`handlers/profile.go`(改邮箱)、`handlers/admin_user.go`(建号/编辑)
|
||||
- **修复**: 新增公共 `validateEmail`(空值放行,非空走 `net/mail.ParseAddress`,与评论处口径一致),四处统一调用
|
||||
- **验证**: ✅ `TestRegisterRejectsInvalidEmail`、`TestProfileEmailValidation`、`TestAdminUserPasswordAndEmailEnforcement`(`abc` 均拒绝、合法邮箱正常)
|
||||
|
||||
### [x] 29. 站点 favicon/logo 上传缺魔数校验(2026-08-27 API 化复审新发现)✅ 2026-08-27
|
||||
- **位置**: `handlers/settings.go`(saveSiteImage)
|
||||
- **问题**: 头像上传(profile.go)与附件上传(attachment.go)均调用 `contentMatchesType` 做魔数一致性校验(#14/#21),但 saveSiteImage 只查扩展名白名单 + Category=image 即 `io.Copy` 落盘——管理员可把 HTML 内容存为 `logos/logo.png`。当前由 `X-Content-Type-Options: nosniff` + 按扩展名的 Content-Type 兜底(浏览器不会执行),但纵深防御链条在此断裂。
|
||||
- **修复**:
|
||||
- [x] saveSiteImage 读取字节后调用 `contentMatchesType(check.Type, content)`,不匹配返回 400(`settings_upload_bad_content`,i18n 中英新增;与头像上传口径一致)
|
||||
- **验证**: ✅ `TestSiteImageUploadRejectsMismatchedContent`(favicon/logo 各:PNG 扩展名 + HTML 字节 → 400;正常 PNG → 200)
|
||||
|
||||
### [x] 30. 最后管理员防线存在 TOCTOU 竞态(2026-08-27 API 化复审新发现)✅ 2026-08-27
|
||||
- **位置**: `handlers/admin_user.go`(UserUpdate 降级检查、UserDelete 删除检查)
|
||||
- **问题**: `adminCount <= 1` 检查与后续 Save/Delete 非原子:两个并发的"降级/删除最后一位管理员"请求可同时通过检查,导致站点失去管理员。SQLite 单写锁下窗口极小;MySQL 部署是真实窗口(需管理员 CSRF 或双开标签配合,可利用性低)。
|
||||
- **修复**:
|
||||
- [x] 检查+写入包进 `db.Transaction`;事务内 `ensureNotLastAdmin` 先锁定管理员集合(MySQL:`SELECT ... FOR UPDATE`,GORM `clause.Locking`)再计数,并发事务串行化后重读
|
||||
- [x] SQLite 无 FOR UPDATE(且纯 Go 驱动连接池可并发读):叠加进程内互斥锁 `lastAdminMu`(应用按设计单实例部署,见 #10 限流器注释)闭合同进程竞态;事务内计数在写锁串行化后重读
|
||||
- **验证**: ✅ `TestConcurrentLastAdminDowngrade`(两位管理员并发降级:恰好 1 成功 1 拒绝,最终管理员 ≥1,`-race` 通过)
|
||||
|
||||
### [x] 31. 普通作者可置顶全站文章——需确认设计意图(2026-08-27 API 化复审新发现)✅ 2026-08-27
|
||||
- **位置**: `handlers/article.go`(ArticleCreate/ArticleUpdate 由 /api/my/articles 复用)、`templates/user/my_article_form.html`(is_top 复选框)
|
||||
- **问题**: my 表单与 API 均接受 `is_top`——任意注册作者可把自己的文章钉在全站首页最顶端(`publishedArticleOrder` 为 is_top DESC 优先),还能自定 `published_at` 影响排序。若"作者可置顶自己的文章"非产品预期,属影响公共展示位的横向越权。
|
||||
- **修复**(确认非设计意图,予收紧):
|
||||
- [x] MyArticleCreate/MyArticleUpdate 经共享实现 `articleCreate/articleUpdate(..., allowIsTop=false)` 强制 `is_top=false`;MyArticleUpdate 保留库中现有值(管理员授权的置顶不因作者编辑而丢失/取消)
|
||||
- [x] my_article_form.html 移除置顶复选框(作者表单不再提供该字段)
|
||||
- **验证**: ✅ `TestMyArticlesCannotPin`(作者 create/update 提交 is_top=true → 落库 false;admin 路径可正常置顶;作者编辑已置顶文章不丢失置顶)
|
||||
|
||||
---
|
||||
|
||||
## P3 — 低优先级 / 观察项
|
||||
|
||||
### [x] 14. 上传不校验文件真实类型 ✅ 2026-08-27
|
||||
- **位置**: `handlers/upload_validator.go`(新增 `contentMatchesType`)、`handlers/attachment.go`、`handlers/profile.go`
|
||||
- **修复**: `mimetype`(v1.4.12 转直接依赖)magic-bytes 检测与扩展名配置的 MimeType 比对(宽容策略:策略为空/`application/octet-stream`/内容不可检测时放行,扩展名白名单仍为主闸);附件 AJAX 400 + 头像 400/表单错误
|
||||
- **验证**: ✅ `TestUploadAttachmentRejectsMismatchedContent`(.txt 内容为 PNG 字节 → 400,真文本 → 200)、`TestContentMatchesTypeTable`(8 用例表驱动)
|
||||
|
||||
### [x] 15. Gravatar MD5 邮箱哈希可反查 ✅ 2026-08-27
|
||||
- **位置**: `models/seed.go`、`models/comment_config.go`(defaultCommentConfig)、`handlers/admin_comment.go`、`templates/admin/comment_list.html`
|
||||
- **修复**: 新部署默认 `UseGravatar=false`(gorm tag default:false 同步);前端注释占位(AuthorInitial + 调色板)已是既有模式;管理员评论列表跟随开关,关闭时不再请求 gravatar.com;管理员可在评论设置页显式重开
|
||||
- **说明**: 线上已存在配置行不受 default 迁移影响,后台关闭即可;协议固有反查风险保留(开启者知情)
|
||||
- **验证**: ✅ `TestGravatarOffByDefault`、`TestAdminCommentListFollowsGravatarSwitch`(关:无 gravatar.com;开:出现)
|
||||
|
||||
### [x] 16. RSS 以 Host 头构造 baseURL ✅ 2026-08-27
|
||||
- **位置**: `models/site_setting.go`(新增 SiteURL)、`handlers/settings.go`、`templates/admin/settings_site.html`、`handlers/rss.go`
|
||||
- **修复**: 站点设置新增规范地址(SiteURL,保存时 trim);RSSFeed 优先使用固定 URL(去尾斜杠),未配置时告警日志 + 回退请求 Host(兼容旧部署)
|
||||
- **验证**: ✅ `TestRSSUsesConfiguredSiteURL`(配置后 Host 头污染不生效 / 未配置回退)
|
||||
|
||||
### [x] 17. bcrypt cost 偏低 ✅ 2026-08-27
|
||||
- **位置**: `models/user.go`(SetPassword)、`handlers/login_ratelimit.go`(dummyHash)
|
||||
- **修复**: `bcrypt.DefaultCost`(10) → 12(models 包常量 `bcryptCost`,dummy 哈希同 cost);已有哈希自适应不失效,下次改密自然升级
|
||||
- **验证**: ✅ 现有认证/限速测试全绿(含 -race);成本升级使登录延迟 ~300ms,配合 #10 限速可接受
|
||||
|
||||
### [x] 25. 登录计时侧信道(用户名枚举)✅ 2026-08-27(与 #10 一并实施)
|
||||
- **位置**: `handlers/auth.go:39-47`
|
||||
- **问题**: 用户不存在时立即返回、不执行 bcrypt;密码错误时执行 bcrypt(~100ms)。响应时间差可用于枚举有效用户名,与未修复的 #10(无速率限制)叠加放大。
|
||||
- **修复**:
|
||||
- [x] 用户不存在时也执行一次 dummy bcrypt 比较(包级预生成哑哈希),抹平时间差;两分支均记录失败计数
|
||||
- **验证**: ✅ 结构保证两分支均执行一次 bcrypt(`TestLoginTimingDoesNotRevealUser` 断言未知用户分支进入 Fail);大样本计时统计属人工运维验证,逻辑上两分支 B 树一致
|
||||
|
||||
### [x] 32. 零碎加固(2026-08-27 API 化复审新发现)✅ 2026-08-27
|
||||
- **位置**: 多处
|
||||
- **问题与修复**:
|
||||
- [x] `admin_user.go` UserCreate/UserUpdate:`status` 无枚举校验,可存任意 int(如 99)——新增 `validUserStatus` 限定 {0,1,2,3},非法 400(i18n `user_status_invalid` 中英新增)
|
||||
- [x] `attachment.go` parseUintParam / parseUintForm:`Sscanf("%d")` 会把 `"5abc"` 宽松解析为 5——改 `strconv.ParseUint` 严格拒绝(无注入风险,值已为数值类型,仅严谨性)
|
||||
- [x] `settings.go` dangerousUploadExtensions:补充 `.xsl` / `.xslt` / `.shtml`(nosniff 已兜底,仅完整性)
|
||||
- **验证**: ✅ `TestUserStatusEnumRejected`(-1/99 → 400 且数据不变;锁定/禁用/正常逐一生效)、`TestParseUintStrict`(路由参数与表单字段的 "5abc"/溢出值拒绝,合法值正常)、`TestAddUploadFileTypeRejectsDangerousExtensions` 扩展用例(.xsl/.xslt/.shtml 拒绝 + 良性 .md 通过)
|
||||
|
||||
---
|
||||
|
||||
## 不需要修复(已确认安全,2026-08-27 复审复核仍成立)
|
||||
|
||||
- SQL 注入:全参数化查询(GORM)——唯一例外见 #19(admin_user.go 3 处字符串条件)
|
||||
- XSS:html/template 自动转义 + 评论双防御(服务端 strip + DOMPurify)
|
||||
- 密码哈希:bcrypt
|
||||
- 附件路径穿越:SHA-256 内容寻址文件名
|
||||
- 线上默认凭据:已修改(已验证)
|
||||
- 注册接口:已关闭(已验证)
|
||||
- 开放重定向:API 响应的 redirect 字段均为服务端常量(APIOK 只接收 handler 硬编码路径),用户输入不进入跳转目标
|
||||
- JS 上下文注入:文章正文 `{{.Article.Content}}` 处于 `<script>` 字符串上下文,html/template 自动做 JS 转义;评论另经 data-md 属性 + DOMPurify 渲染
|
||||
- API 越权(IDOR):/api/my/* 全部带 author_id 所有权约束;附件三端点均有 admin/上传者/文章作者校验;admin 与 my 的文章 CRUD 分组挂不同中间件
|
||||
- API 化后旧表单端点已全部移除:不存在新旧端点权限口径不一致的并行暴露面
|
||||
|
||||
---
|
||||
|
||||
## 建议执行顺序
|
||||
|
||||
**#1–#32 全部修复并验证完毕(2026-08-27)。** 实施顺序:
|
||||
1. ~~#26 请求体大小限制~~ ✅ 2026-08-27
|
||||
2. ~~#27/#28 注册与评论限流~~ ✅ 2026-08-27(新增 `handlers/rate_limit.go` 固定窗口限流器,可与 #26 的中间件基建衔接)
|
||||
3. ~~#29 favicon/logo 魔数校验~~、~~#30 最后管理员事务化~~ ✅ 2026-08-27(各自独立小改)
|
||||
4. ~~#31 置顶权限~~ ✅ 2026-08-27(确认"作者可置顶"非产品预期,按收紧方案实施:作者 create/update 忽略 is_top,表单移除复选框;管理员授权置顶不因作者编辑丢失)
|
||||
5. ~~#32 零碎项~~ ✅ 2026-08-27(status 枚举校验 / 严格 uint 解析 / 危险扩展名补充)
|
||||
|
||||
历史遗留观察项(不阻塞):
|
||||
|
||||
- #18 方案 A:数据库文件移出存储根,需迁移存量 blog.db
|
||||
- #15 Gravatar 开启时的反查风险(管理员知情)
|
||||
- #9 本地化 vendor 库版本升级提醒(随浏览器生态更新,重建 `scripts/build_tailwind.sh` 与 vendor 文件)
|
||||
@@ -0,0 +1,42 @@
|
||||
# API 化改造 Todo
|
||||
|
||||
操作接口改为 `/api/` 前缀的 JSON API(POST/PUT/DELETE、码+文案错误响应),页面路由保持不变。
|
||||
|
||||
## 基建(high)
|
||||
|
||||
- [x] 1. 新建 `handlers/api.go`:`APIError(c, status, trKey)`、`APIOK(c, redirect, data)`、JSON 绑定/解析 helper
|
||||
- [x] 2. `middleware/auth.go`:`AuthRequired`/`AdminRequired` 按 `/api` 路径前缀分支 401/403 JSON(非 API 保持 302)
|
||||
- [x] 3. `main.go`:注册 `/api` 分组(auth/article/admin/profile/my 子组)+ gin 静态/参数路由冲突冒烟测试(新增 `TestRegisterRoutesSmoke`,含附件端点搬移,`LoginRateLimiter` 导出)
|
||||
- [x] 4. `templates/layouts/base.html`:增加 `blogAPI`/`blogForm`/`blogShowError` 共享 fetch 助手(CSRF 头、`e.submitter` 状态按钮、bool 复选框转换)
|
||||
|
||||
## 零行为搬移(medium)
|
||||
|
||||
- [x] 5. 附件三件套(admin/my)+ `/api/profile/avatar` 换注册路径(路由已在基建 commit 搬移)
|
||||
- [x] 6. 搬移端点模板更新:`article_create.html` 3 处 fetch URL、`profile.html` 1 处(删除改用 DELETE)
|
||||
|
||||
## 功能改造(medium)
|
||||
|
||||
- [x] 7. 认证 API:`auth.go` Login/Register/Logout JSON 化(429 限流、会话轮换保留)
|
||||
- [x] 8. 认证模板:`login.html`/`register.html` 改 fetch + 错误 div `id`;base.html logout 改 fetch(logout-form 全局委托)
|
||||
- [x] 9. 评论 API:`comment.go` PostComment JSON 化(校验码复用 i18n 键)
|
||||
- [x] 10. 评论 API:`admin_comment.go` approve/reject/delete JSON 化
|
||||
- [x] 11. 评论模板:`article.html` 评论表单 fetch、`comment_list.html` 操作后 reload
|
||||
- [x] 12. 文章 CRUD API:`article.go`/`my_articles.go` 去 renderForm/Redirect 改 JSON(表单字段加 json tag)
|
||||
- [x] 13. 文章模板:`article_create.html`/`my_article_form.html`/`article_list.html`/`my_articles.html` 改 fetch(`easyMDE.value()`、`e.submitter`)
|
||||
- [x] 14. 用户 API:`admin_user.go` UserCreate/Update/Delete JSON 化(自防/最后管理员拦截改 403)
|
||||
- [x] 15. 用户模板:`user_form.html`/`user_list.html` 改 fetch
|
||||
- [x] 16. 设置 API:`settings.go` 5 组 save JSON 化;site favicon/logo 拆出 `POST /api/admin/settings/site/favicon|logo`
|
||||
- [x] 17. 设置模板:`settings_site`/`settings_navlinks`/`settings_upload`/`settings_download`/`settings_comment` 5 页改 fetch(blogSettingsForm 委托)
|
||||
- [x] 18. 个人资料 API:`profile.go` UpdateProfile JSON 化(头像走 `/api/profile/avatar`)
|
||||
- [x] 19. 个人资料模板:`profile.html` 文本表单改 fetch
|
||||
|
||||
## 测试与收尾(high)
|
||||
|
||||
- [x] 20. 新增 `handlers/api_test.go`:happy path / 校验码 / 401 / 403 / CSRF 头 / 429 / 409
|
||||
- [x] 21. 更新 `security_test.go`/`p2_validation_test.go`/`p3_upload_test.go`/`session_upload_security_test.go` 到新 URL 与 JSON 断言
|
||||
- [x] 22. `go build/vet/test ./...`(含 -count=1 -race)全绿;README 路由表同步
|
||||
|
||||
## 执行说明
|
||||
|
||||
- 每步只改路由/响应层,不动解析校验逻辑(JSON binding 替换 PostForm 读取),单步可编译可测
|
||||
- 并发状态:单一编辑者(本会话从第 1 项开始逐步执行)
|
||||
@@ -0,0 +1,25 @@
|
||||
// Package buildinfo 保存编译时通过 -ldflags -X 注入的版本信息,
|
||||
// 部署时由 install_linux.sh 注入 Git 提交哈希与编译时间。
|
||||
package buildinfo
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Commit 是编译时的 Git 短提交哈希(-X go_blog/buildinfo.Commit=<sha>)。
|
||||
// 未注入时回退为 "dev"。
|
||||
var Commit string
|
||||
|
||||
// BuildTime 是编译时间(UTC,-X go_blog/buildinfo.BuildTime=<time>)。
|
||||
// 未注入时回退为 "unknown"。
|
||||
var BuildTime string
|
||||
|
||||
// String 返回展示用的版本字符串,如 "v443eccb · 2026-08-27T12:00:00Z"。
|
||||
func String() string {
|
||||
commit, buildTime := Commit, BuildTime
|
||||
if commit == "" {
|
||||
commit = "dev"
|
||||
}
|
||||
if buildTime == "" {
|
||||
buildTime = "unknown"
|
||||
}
|
||||
return fmt.Sprintf("v%s · %s", commit, buildTime)
|
||||
}
|
||||
+50
-32
@@ -1,8 +1,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config holds all application configuration.
|
||||
// Config 保存全部应用配置。
|
||||
type Config struct {
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Web WebConfig `yaml:"web"`
|
||||
@@ -19,24 +19,32 @@ type Config struct {
|
||||
Secret string `yaml:"secret"`
|
||||
}
|
||||
|
||||
// DatabaseConfig holds database-specific configuration.
|
||||
// DatabaseConfig 保存数据库相关配置。
|
||||
type DatabaseConfig struct {
|
||||
Type string `yaml:"type"` // "sqlite" (default) or "mysql"
|
||||
DSN string `yaml:"dsn"` // MySQL connection string (required when type is "mysql")
|
||||
Type string `yaml:"type"` // "sqlite"(默认)或 "mysql"
|
||||
DSN string `yaml:"dsn"` // MySQL 连接字符串(type 为 "mysql" 时必填)
|
||||
}
|
||||
|
||||
// WebConfig holds web-server listening configuration.
|
||||
// WebConfig 保存 Web 服务器监听配置。
|
||||
type WebConfig struct {
|
||||
Port string `yaml:"port"` // TCP port, "" or "0" to disable
|
||||
Socket string `yaml:"socket"` // Unix socket path, "" to disable
|
||||
Port string `yaml:"port"` // TCP 端口,"" 或 "0" 表示禁用
|
||||
Socket string `yaml:"socket"` // Unix Socket 路径,"" 表示禁用
|
||||
// TrustedProxies 列出可信代理 IP/CIDR,这些代理的 X-Forwarded-For /
|
||||
// X-Forwarded-Proto 请求头将被信任(例如位于应用前方的 Caddy/nginx
|
||||
// 服务器)。默认为回环地址。如果应用直接暴露给客户端,请保持默认值,
|
||||
// 以免客户端伪造 X-Forwarded-For 欺骗记录中的 IP。
|
||||
TrustedProxies []string `yaml:"trusted_proxies"`
|
||||
}
|
||||
|
||||
// defaultTrustedProxies 在配置中省略 trusted_proxies 时使用。
|
||||
var defaultTrustedProxies = []string{"127.0.0.1", "::1"}
|
||||
|
||||
const defaultPort = "8080"
|
||||
|
||||
// mysqlExampleDSN is written into new config files as a reference.
|
||||
// mysqlExampleDSN 会写入新建的配置文件,作为参考示例。
|
||||
const mysqlExampleDSN = "user:password@tcp(127.0.0.1:3306)/blog_go?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
|
||||
// getConfigPath returns the OS-aware config directory and config file path.
|
||||
// getConfigPath 返回按操作系统区分的配置目录和配置文件路径。
|
||||
func getConfigPath() (dir, file string) {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
@@ -50,7 +58,7 @@ func getConfigPath() (dir, file string) {
|
||||
return
|
||||
}
|
||||
|
||||
// getDefaultStoragePath returns the OS-aware default storage path.
|
||||
// getDefaultStoragePath 返回按操作系统区分的默认存储路径。
|
||||
func getDefaultStoragePath() string {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
@@ -62,23 +70,26 @@ func getDefaultStoragePath() string {
|
||||
}
|
||||
}
|
||||
|
||||
// generateSecret returns a random-ish hex string for the session secret.
|
||||
// generateSecret 为会话密钥生成密码学随机的十六进制字符串。
|
||||
// crypto/rand 的失败无法恢复,因此程序将直接终止,而不会退回到可预测的值。
|
||||
func generateSecret() string {
|
||||
hostname, _ := os.Hostname()
|
||||
input := fmt.Sprintf("%s-%d", hostname, os.Getpid())
|
||||
hash := sha256.Sum256([]byte(input))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
log.Fatalf("Failed to generate session secret: %v", err)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// getDefaultSocketPath returns the OS-aware default unix socket path.
|
||||
// getDefaultSocketPath 返回按操作系统区分的默认 Unix Socket 路径。
|
||||
func getDefaultSocketPath() string {
|
||||
if runtime.GOOS == "linux" {
|
||||
return "/run/blog_go/web.sock"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
// LoadConfig reads the config file or creates one with defaults.
|
||||
// If customPath is non-empty, it overrides the OS-aware config file path.
|
||||
|
||||
// LoadConfig 读取配置文件;若不存在则按默认值创建。
|
||||
// 若 customPath 非空,则覆盖按操作系统区分的配置文件路径。
|
||||
func LoadConfig(customPath string) *Config {
|
||||
configDir, configFile := getConfigPath()
|
||||
if customPath != "" {
|
||||
@@ -87,7 +98,7 @@ func LoadConfig(customPath string) *Config {
|
||||
}
|
||||
defaultPath := getDefaultStoragePath()
|
||||
|
||||
// Check if config file exists; create with defaults if not.
|
||||
// 检查配置文件是否存在;不存在则按默认值创建。
|
||||
if _, err := os.Stat(configFile); os.IsNotExist(err) {
|
||||
log.Printf("Config file not found at %s, creating with defaults...", configFile)
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
@@ -112,19 +123,19 @@ func LoadConfig(customPath string) *Config {
|
||||
log.Fatalf("Failed to marshal default config: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configFile, data, 0644); err != nil {
|
||||
if err := os.WriteFile(configFile, data, 0640); err != nil {
|
||||
log.Fatalf("Failed to write config file %s: %v", configFile, err)
|
||||
}
|
||||
// SECURITY_TODO #11:配置文件保存会话密钥;仅允许所有者读取
|
||||
// (install_linux.sh 已应用 0640 权限)。
|
||||
log.Printf("Default config created at %s", configFile)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Read existing config file.
|
||||
// 读取现有配置文件。
|
||||
data, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
log.Printf("Warning: could not read config file %s: %v, using defaults", configFile, err)
|
||||
cfg := &Config{}
|
||||
return applyDefaults(cfg, defaultPath)
|
||||
log.Fatalf("Failed to read config file %s: %v", configFile, err)
|
||||
}
|
||||
|
||||
cfg := &Config{}
|
||||
@@ -132,16 +143,19 @@ func LoadConfig(customPath string) *Config {
|
||||
log.Printf("Warning: malformed config file %s: %v, using defaults", configFile, err)
|
||||
}
|
||||
|
||||
return applyDefaults(cfg, defaultPath)
|
||||
return applyDefaults(cfg, defaultPath, configFile)
|
||||
}
|
||||
|
||||
// applyDefaults fills zero-value fields with sensible defaults.
|
||||
func applyDefaults(cfg *Config, defaultPath string) *Config {
|
||||
// If the entire web block is empty (old config without "web" key),
|
||||
// fill default port so the app still starts on 8080.
|
||||
// applyDefaults 以合理的默认值填充零值字段。
|
||||
func applyDefaults(cfg *Config, defaultPath, configFile string) *Config {
|
||||
// 如果整个 web 块为空(旧配置中没有 "web" 键),
|
||||
// 填充默认端口,使应用仍能从 8080 启动。
|
||||
if cfg.Web.Port == "" && cfg.Web.Socket == "" {
|
||||
cfg.Web.Port = defaultPort
|
||||
}
|
||||
if len(cfg.Web.TrustedProxies) == 0 {
|
||||
cfg.Web.TrustedProxies = defaultTrustedProxies
|
||||
}
|
||||
if cfg.Database.Type == "" {
|
||||
cfg.Database.Type = "sqlite"
|
||||
}
|
||||
@@ -149,7 +163,11 @@ func applyDefaults(cfg *Config, defaultPath string) *Config {
|
||||
cfg.Path = defaultPath
|
||||
}
|
||||
if cfg.Secret == "" {
|
||||
cfg.Secret = generateSecret()
|
||||
// 配置文件存在但没有密钥。拒绝启动:静默生成的回退值要么
|
||||
// 可预测(旧的 hostname+pid 方案),要么导致每次重启都使所有会话失效。
|
||||
log.Fatalf("Config file %s is missing a session secret. "+
|
||||
"Add a random value, e.g. `secret: %s`, and restart.",
|
||||
configFile, generateSecret())
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestConfigFileCreatedNotWorldReadable 覆盖 SECURITY_TODO #11:
|
||||
// 配置文件(内嵌会话密钥)不得被组/其他用户读取。
|
||||
func TestConfigFileCreatedNotWorldReadable(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
LoadConfig(path)
|
||||
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("config file not created: %v", err)
|
||||
}
|
||||
if perm := st.Mode().Perm(); perm != 0640 {
|
||||
t.Fatalf("config perms = %v, want 0640", perm)
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,12 @@ module go_blog
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gabriel-vasile/mimetype v1.4.12
|
||||
github.com/gin-contrib/sessions v1.1.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/image v0.43.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
@@ -19,7 +21,6 @@ require (
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
@@ -48,7 +49,6 @@ require (
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/image v0.43.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
|
||||
+3
-3
@@ -9,7 +9,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AdminDashboard renders the protected admin dashboard.
|
||||
// AdminDashboard 渲染受保护的管理后台首页。
|
||||
func AdminDashboard(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
@@ -18,14 +18,14 @@ func AdminDashboard(db *gorm.DB) gin.HandlerFunc {
|
||||
data["Title"] = tr["dash_page_title"]
|
||||
data["Username"] = username
|
||||
|
||||
// Article counts: total and published.
|
||||
// 文章数量:总数与已发布数。
|
||||
var postCount, publishedCount int64
|
||||
db.Model(&models.Article{}).Count(&postCount)
|
||||
db.Model(&models.Article{}).Where("status = ?", models.ArticlePublished).Count(&publishedCount)
|
||||
data["PostCount"] = postCount
|
||||
data["PublishedCount"] = publishedCount
|
||||
|
||||
// User count for the stats card.
|
||||
// 统计卡片中的用户数。
|
||||
var userCount int64
|
||||
db.Model(&models.User{}).Count(&userCount)
|
||||
data["UserCount"] = userCount
|
||||
|
||||
@@ -10,12 +10,12 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ViewAnalyticsPage renders the admin analytics page showing article view statistics.
|
||||
// ViewAnalyticsPage 渲染展示文章浏览统计的后台分析页面。
|
||||
func ViewAnalyticsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
|
||||
// Parse filters from query params
|
||||
// 从查询参数解析筛选条件
|
||||
articleTitle := c.Query("article_title")
|
||||
userIDStr := c.Query("user_id")
|
||||
ip := c.Query("ip")
|
||||
@@ -27,13 +27,13 @@ func ViewAnalyticsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Build query for view records
|
||||
// 构建浏览记录查询
|
||||
query := db.Model(&models.ArticleView{}).
|
||||
Preload("Article").
|
||||
Preload("User").
|
||||
Order("created_at DESC")
|
||||
|
||||
// Filter by article title (join with articles table)
|
||||
// 按文章标题筛选(联表 articles)
|
||||
if articleTitle != "" {
|
||||
query = query.Joins("JOIN articles ON article_views.article_id = articles.id").
|
||||
Where("articles.title LIKE ?", "%"+articleTitle+"%")
|
||||
@@ -48,7 +48,7 @@ func ViewAnalyticsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
query = query.Where("is_bot = ?", false)
|
||||
}
|
||||
|
||||
// Pagination
|
||||
// 分页
|
||||
pageSize := 50
|
||||
offset := (page - 1) * pageSize
|
||||
var views []models.ArticleView
|
||||
@@ -58,7 +58,7 @@ func ViewAnalyticsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
|
||||
hasMore := int64(offset+len(views)) < totalCount
|
||||
|
||||
// Calculate global statistics
|
||||
// 计算全局统计
|
||||
var stats struct {
|
||||
TotalViews int64
|
||||
UniqueIPs int64
|
||||
@@ -72,7 +72,7 @@ func ViewAnalyticsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
db.Model(&models.ArticleView{}).Distinct("ip").Count(&stats.UniqueIPs)
|
||||
db.Model(&models.ArticleView{}).Where("user_id IS NOT NULL").Distinct("user_id").Count(&stats.UniqueUsers)
|
||||
|
||||
// Per-article statistics
|
||||
// 按文章统计
|
||||
type ArticleStat struct {
|
||||
ArticleID uint
|
||||
Title string
|
||||
|
||||
+37
-18
@@ -10,13 +10,15 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// adminCommentPageSize bounds the number of comments shown per admin page.
|
||||
// adminCommentPageSize 限制后台每页显示的评论数量。
|
||||
const adminCommentPageSize = 30
|
||||
|
||||
// commentListView is a Comment plus derived display fields for the admin list.
|
||||
// commentListView 是 Comment 加上后台列表所需的派生展示字段。
|
||||
type commentListView struct {
|
||||
models.Comment
|
||||
GravatarURL string
|
||||
Initial string
|
||||
AvatarColor string
|
||||
MaskedEmail string
|
||||
StatusLabel string
|
||||
StatusBadge string
|
||||
@@ -24,8 +26,8 @@ type commentListView struct {
|
||||
ArticleSlug string
|
||||
}
|
||||
|
||||
// CommentListPage renders the admin comment moderation list, filtered by
|
||||
// status via ?status=pending|approved|rejected|all.
|
||||
// CommentListPage 渲染后台评论审核列表,可通过
|
||||
// ?status=pending|approved|rejected|all 按状态筛选。
|
||||
func CommentListPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
@@ -41,7 +43,7 @@ func CommentListPage(db *gorm.DB) gin.HandlerFunc {
|
||||
case "rejected":
|
||||
q = q.Where("status = ?", models.CommentRejected)
|
||||
case "all":
|
||||
// no filter
|
||||
// 不过滤
|
||||
default: // pending
|
||||
status = "pending"
|
||||
q = q.Where("status = ?", models.CommentPending)
|
||||
@@ -50,7 +52,7 @@ func CommentListPage(db *gorm.DB) gin.HandlerFunc {
|
||||
var comments []models.Comment
|
||||
q.Limit(adminCommentPageSize).Find(&comments)
|
||||
|
||||
// Pull referenced articles in one query to avoid N+1.
|
||||
// 用一次查询拉取引用的文章,避免 N+1。
|
||||
ids := make(map[uint]struct{})
|
||||
for _, cm := range comments {
|
||||
ids[cm.ArticleID] = struct{}{}
|
||||
@@ -69,11 +71,20 @@ func CommentListPage(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
views := make([]commentListView, 0, len(comments))
|
||||
// SECURITY_TODO #15:后台列表遵循平台开关——关闭时不发起 Gravatar
|
||||
// 请求(改为使用前端占位头像)。
|
||||
useGravatar := models.GetCommentConfig().UseGravatar
|
||||
for _, cm := range comments {
|
||||
gravURL := ""
|
||||
if useGravatar {
|
||||
gravURL = cm.GravatarURL(40)
|
||||
}
|
||||
v := commentListView{
|
||||
Comment: cm,
|
||||
GravatarURL: cm.GravatarURL(40),
|
||||
GravatarURL: gravURL,
|
||||
MaskedEmail: cm.MaskedEmail(),
|
||||
Initial: cm.AuthorInitial(),
|
||||
AvatarColor: avatarColorFor(cm.ID),
|
||||
}
|
||||
if a, ok := articleMap[cm.ArticleID]; ok {
|
||||
v.ArticleTitle = a.Title
|
||||
@@ -93,7 +104,7 @@ func CommentListPage(db *gorm.DB) gin.HandlerFunc {
|
||||
views = append(views, v)
|
||||
}
|
||||
|
||||
// Pending count for the tab badge.
|
||||
// 标签徽章使用的待审数量。
|
||||
var pendingCount int64
|
||||
db.Model(&models.Comment{}).Where("status = ?", models.CommentPending).Count(&pendingCount)
|
||||
|
||||
@@ -118,33 +129,41 @@ func CommentListPage(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// CommentApprove marks a comment approved.
|
||||
// CommentApprove 将评论标记为通过。
|
||||
func CommentApprove(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if id := c.Param("id"); id != "" {
|
||||
if id := parseUintParam(c, "id"); id == 0 {
|
||||
APIError(c, http.StatusBadRequest, "api_invalid_request")
|
||||
return
|
||||
} else {
|
||||
db.Model(&models.Comment{}).Where("id = ?", id).Update("status", models.CommentApproved)
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/comments?status=pending&saved=1&msg=approved")
|
||||
APIOK(c, "/admin/comments?status=pending&saved=1&msg=approved", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// CommentReject marks a comment rejected (hidden from the front end, retained
|
||||
// in the admin list).
|
||||
// CommentReject 将评论标记为拒绝(前端隐藏,后台列表保留)。
|
||||
func CommentReject(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if id := c.Param("id"); id != "" {
|
||||
if id := parseUintParam(c, "id"); id == 0 {
|
||||
APIError(c, http.StatusBadRequest, "api_invalid_request")
|
||||
return
|
||||
} else {
|
||||
db.Model(&models.Comment{}).Where("id = ?", id).Update("status", models.CommentRejected)
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/comments?status=pending&saved=1&msg=rejected")
|
||||
APIOK(c, "/admin/comments?status=pending&saved=1&msg=rejected", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// CommentDelete soft-deletes a comment.
|
||||
// CommentDelete 软删除一条评论。
|
||||
func CommentDelete(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if id := c.Param("id"); id != "" {
|
||||
if id := parseUintParam(c, "id"); id == 0 {
|
||||
APIError(c, http.StatusBadRequest, "api_invalid_request")
|
||||
return
|
||||
} else {
|
||||
db.Where("id = ?", id).Delete(&models.Comment{})
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/comments?status=all&saved=1&msg=deleted")
|
||||
APIOK(c, "/admin/comments?status=all&saved=1&msg=deleted", nil)
|
||||
}
|
||||
}
|
||||
+204
-110
@@ -1,36 +1,40 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go_blog/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// userForm holds the posted user fields plus rendering metadata for the shared
|
||||
// create/edit form template.
|
||||
// userForm 是后台用户创建/更新接口的 JSON 请求体,
|
||||
// 同时供共享创建/编辑表单模板的渲染使用(IsEdit/Action/TitleText 非绑定字段)。
|
||||
type userForm struct {
|
||||
ID uint
|
||||
Username string
|
||||
Password string
|
||||
DisplayName string
|
||||
Email string
|
||||
Gender string
|
||||
Birthday string // YYYY-MM-DD from the date input
|
||||
Role string
|
||||
Status int
|
||||
ID uint `json:"-"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
Gender string `json:"gender"`
|
||||
Birthday string `json:"birthday"` // 来自日期输入的 YYYY-MM-DD
|
||||
Role string `json:"role"`
|
||||
Status int `json:"status"`
|
||||
IsEdit bool
|
||||
Action string
|
||||
TitleText string
|
||||
}
|
||||
|
||||
// userListView augments a User with pre-rendered labels/badges so the template
|
||||
// never invokes methods on an interface{}-wrapped struct.
|
||||
// userListView 为 User 附加预渲染的标签/徽章,
|
||||
// 使模板绝不调用包装为 interface{} 的结构体上的方法。
|
||||
type userListView struct {
|
||||
models.User
|
||||
RoleLabel string
|
||||
@@ -39,30 +43,24 @@ type userListView struct {
|
||||
StatusBadge string
|
||||
}
|
||||
|
||||
// parseUserForm reads the user form fields from the request.
|
||||
func parseUserForm(c *gin.Context) userForm {
|
||||
// Status is always sent from the <select> (0/1/2/3); treat an absent field
|
||||
// as Normal, but keep an explicit 0 (Disabled) intact.
|
||||
status := models.StatusNormal
|
||||
if raw := strings.TrimSpace(c.PostForm("status")); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil {
|
||||
status = n
|
||||
}
|
||||
}
|
||||
return userForm{
|
||||
ID: uintFormID(c.Param("id")),
|
||||
Username: strings.TrimSpace(c.PostForm("username")),
|
||||
Password: c.PostForm("password"),
|
||||
DisplayName: strings.TrimSpace(c.PostForm("display_name")),
|
||||
Email: strings.TrimSpace(c.PostForm("email")),
|
||||
Gender: strings.TrimSpace(c.PostForm("gender")),
|
||||
Birthday: strings.TrimSpace(c.PostForm("birthday")),
|
||||
Role: strings.TrimSpace(c.PostForm("role")),
|
||||
Status: status,
|
||||
// parseUserFormJSON 绑定 JSON 请求体的用户字段并去除空白。
|
||||
// 绑定失败时已写入 400 响应并返回 ok=false。ID 来自路由参数。
|
||||
func parseUserFormJSON(c *gin.Context) (userForm, bool) {
|
||||
var f userForm
|
||||
if !bindJSON(c, &f) {
|
||||
return f, false
|
||||
}
|
||||
f.ID = uintFormID(c.Param("id"))
|
||||
f.Username = strings.TrimSpace(f.Username)
|
||||
f.DisplayName = strings.TrimSpace(f.DisplayName)
|
||||
f.Email = strings.TrimSpace(f.Email)
|
||||
f.Gender = strings.TrimSpace(f.Gender)
|
||||
f.Birthday = strings.TrimSpace(f.Birthday)
|
||||
f.Role = strings.TrimSpace(f.Role)
|
||||
return f, true
|
||||
}
|
||||
|
||||
// uintFormID parses a route :id into a uint (0 when absent/invalid).
|
||||
// uintFormID 将路由 :id 解析为 uint(不存在/非法时为 0)。
|
||||
func uintFormID(s string) uint {
|
||||
if s == "" {
|
||||
return 0
|
||||
@@ -74,8 +72,49 @@ func uintFormID(s string) uint {
|
||||
return uint(n)
|
||||
}
|
||||
|
||||
// applyUserFormToData writes the form values into the template data map so the
|
||||
// form is repopulated on render (initial load or validation error).
|
||||
// validUserStatus 报告后台用户表单提交的状态值是否属于枚举
|
||||
// {StatusDisabled(0), StatusNormal(1), StatusLocked(2), StatusUnactivated(3)}
|
||||
// (SECURITY_TODO #32)。此前 status 无枚举校验,可写入任意 int
|
||||
// (如 99),产生界面无法解释的状态。
|
||||
func validUserStatus(s int) bool {
|
||||
switch s {
|
||||
case models.StatusDisabled, models.StatusNormal, models.StatusLocked, models.StatusUnactivated:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// errLastAdminRemoval 与 lastAdminMu 实现"最后管理员"防线
|
||||
// (SECURITY_TODO #30)。原实现先计数再写入,两者之间无原子性:
|
||||
// 两个并发的"降级/删除倒数第二位管理员"请求可同时通过检查,
|
||||
// 导致站点失去管理员。修复后检查与写入包进同一事务:
|
||||
// - MySQL:事务内 SELECT ... FOR UPDATE 锁定管理员集合,
|
||||
// 并发事务在锁上排队,先提交者生效,后到者重读计数后拒绝;
|
||||
// - SQLite:不支持 FOR UPDATE,但其写锁串行化 + 本进程互斥锁
|
||||
// (应用按设计单实例部署,见 LoginRateLimiter 注释)双保险。
|
||||
var errLastAdminRemoval = errors.New("cannot remove the last admin")
|
||||
|
||||
var lastAdminMu sync.Mutex
|
||||
|
||||
// ensureNotLastAdmin 在事务内读取管理员数量(MySQL 下为锁定读取)。
|
||||
// 仅剩 1 位(或 0 位)管理员时返回 errLastAdminRemoval。
|
||||
func ensureNotLastAdmin(tx *gorm.DB) error {
|
||||
q := tx.Model(&models.User{}).Where("role = ?", models.RoleAdmin)
|
||||
if tx.Dialector.Name() != "sqlite" {
|
||||
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
var admins []models.User
|
||||
if err := q.Find(&admins).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(admins) <= 1 {
|
||||
return errLastAdminRemoval
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyUserFormToData 将表单值写入模板数据映射,
|
||||
// 使渲染时表单被重新填充(初次加载或校验错误)。
|
||||
func applyUserFormToData(data gin.H, f userForm) {
|
||||
data["FormID"] = f.ID
|
||||
data["FormUsername"] = f.Username
|
||||
@@ -91,8 +130,7 @@ func applyUserFormToData(data gin.H, f userForm) {
|
||||
data["FormTitleText"] = f.TitleText
|
||||
}
|
||||
|
||||
// renderUserForm renders the shared user form template with the given form
|
||||
// values and optional error message.
|
||||
// renderUserForm 使用给定的表单值和可选的错误消息渲染共享的用户表单模板。
|
||||
func renderUserForm(c *gin.Context, f userForm, errMsg string) {
|
||||
tr := getTr(c)
|
||||
data := DefaultData(c)
|
||||
@@ -100,7 +138,7 @@ func renderUserForm(c *gin.Context, f userForm, errMsg string) {
|
||||
if errMsg != "" {
|
||||
data["Error"] = errMsg
|
||||
}
|
||||
// Role/status options for the <select> elements.
|
||||
// <select> 元素的角色/状态选项。
|
||||
data["RoleAdmin"] = models.RoleAdmin
|
||||
data["RoleAuthor"] = models.RoleAuthor
|
||||
data["StatusNormal"] = models.StatusNormal
|
||||
@@ -114,7 +152,7 @@ func renderUserForm(c *gin.Context, f userForm, errMsg string) {
|
||||
c.HTML(http.StatusOK, "user_form", data)
|
||||
}
|
||||
|
||||
// UserListPage renders the admin user management list.
|
||||
// UserListPage 渲染后台用户管理列表。
|
||||
func UserListPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
@@ -178,7 +216,7 @@ func UserListPage(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// UserCreatePage renders the empty user creation form.
|
||||
// UserCreatePage 渲染空白用户创建表单。
|
||||
func UserCreatePage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
@@ -192,32 +230,46 @@ func UserCreatePage(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// UserCreate handles POST to create a new user.
|
||||
// UserCreate 处理 POST 创建新用户。
|
||||
func UserCreate(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
f := parseUserForm(c)
|
||||
f.Action = "/admin/users/new"
|
||||
f.TitleText = tr["user_create_title"]
|
||||
f.IsEdit = false
|
||||
f, ok := parseUserFormJSON(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if f.Username == "" {
|
||||
renderUserForm(c, f, tr["user_username_required"])
|
||||
APIError(c, http.StatusBadRequest, "user_username_required")
|
||||
return
|
||||
}
|
||||
// SECURITY_TODO #32:status 必须属于枚举 {0,1,2,3}。
|
||||
if !validUserStatus(f.Status) {
|
||||
APIError(c, http.StatusBadRequest, "user_status_invalid")
|
||||
return
|
||||
}
|
||||
if f.Password == "" {
|
||||
renderUserForm(c, f, tr["user_password_required"])
|
||||
APIError(c, http.StatusBadRequest, "user_password_required")
|
||||
return
|
||||
}
|
||||
// SECURITY (#23):执行平台最小密码长度。
|
||||
if !validatePassword(f.Password) {
|
||||
APIError(c, http.StatusBadRequest, "user_password_short")
|
||||
return
|
||||
}
|
||||
// SECURITY (#24):拒绝格式非法的邮箱地址。
|
||||
if !validateEmail(f.Email) {
|
||||
APIError(c, http.StatusBadRequest, "user_email_invalid")
|
||||
return
|
||||
}
|
||||
if f.Role == "" {
|
||||
f.Role = models.RoleAuthor
|
||||
}
|
||||
|
||||
// Username must be unique.
|
||||
// 用户名必须唯一。
|
||||
var exists int64
|
||||
db.Model(&models.User{}).Where("username = ?", f.Username).Count(&exists)
|
||||
if exists > 0 {
|
||||
renderUserForm(c, f, tr["user_username_exists"])
|
||||
APIError(c, http.StatusConflict, "user_username_exists")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -233,22 +285,29 @@ func UserCreate(db *gorm.DB) gin.HandlerFunc {
|
||||
user.Birthday = &t
|
||||
}
|
||||
if err := user.SetPassword(f.Password); err != nil {
|
||||
renderUserForm(c, f, tr["article_error"])
|
||||
APIError(c, http.StatusInternalServerError, "article_error")
|
||||
return
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
renderUserForm(c, f, tr["article_error"])
|
||||
APIError(c, http.StatusInternalServerError, "article_error")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/users?saved=1&msg=created")
|
||||
APIOK(c, "/admin/users?saved=1&msg=created", gin.H{"user_id": user.ID})
|
||||
}
|
||||
}
|
||||
|
||||
// UserEditPage renders the user edit form prefilled with an existing user.
|
||||
// UserEditPage 渲染预填现有用户的编辑表单。
|
||||
func UserEditPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
id := c.Param("id")
|
||||
// SECURITY (#19):路由参数必须先解析为数值 id 再交给 GORM——
|
||||
// 原始字符串作为单一条件传给 First() 时会按 SQL WHERE 子句拼接。
|
||||
id := uintFormID(c.Param("id"))
|
||||
if id == 0 {
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, id).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
@@ -268,58 +327,62 @@ func UserEditPage(db *gorm.DB) gin.HandlerFunc {
|
||||
Role: user.Role,
|
||||
Status: user.Status,
|
||||
IsEdit: true,
|
||||
Action: "/admin/users/" + id + "/edit",
|
||||
Action: fmt.Sprintf("/admin/users/%d/edit", user.ID),
|
||||
TitleText: tr["user_edit_title"],
|
||||
}, "")
|
||||
}
|
||||
}
|
||||
|
||||
// UserUpdate handles POST to update an existing user.
|
||||
// UserUpdate 处理 POST 更新现有用户。
|
||||
func UserUpdate(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
id := c.Param("id")
|
||||
f := parseUserForm(c)
|
||||
f.ID = uintFormID(id)
|
||||
f.IsEdit = true
|
||||
f.Action = "/admin/users/" + id + "/edit"
|
||||
f.TitleText = tr["user_edit_title"]
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, id).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
f, ok := parseUserFormJSON(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// SECURITY (#19):在触碰 GORM 前拒绝非数值 id(参见 UserEditPage)。
|
||||
if f.ID == 0 {
|
||||
APIError(c, http.StatusBadRequest, "api_invalid_request")
|
||||
return
|
||||
}
|
||||
// SECURITY_TODO #32:status 必须属于枚举 {0,1,2,3}。
|
||||
if !validUserStatus(f.Status) {
|
||||
APIError(c, http.StatusBadRequest, "user_status_invalid")
|
||||
return
|
||||
}
|
||||
|
||||
// Keep the original username (it is the login key + article FK source).
|
||||
var user models.User
|
||||
if err := db.First(&user, f.ID).Error; err != nil {
|
||||
APIError(c, http.StatusNotFound, "user_not_found")
|
||||
return
|
||||
}
|
||||
|
||||
// 保留原用户名(它是登录键 + 文章外键的来源)。
|
||||
f.Username = user.Username
|
||||
|
||||
// SECURITY (#23/#24):在进行任何其他修改前校验提交的密码/邮箱——
|
||||
// 密码重置或个人资料编辑必须遵守与注册相同的规则。
|
||||
if f.Password != "" && !validatePassword(f.Password) {
|
||||
APIError(c, http.StatusBadRequest, "user_password_short")
|
||||
return
|
||||
}
|
||||
if !validateEmail(f.Email) {
|
||||
APIError(c, http.StatusBadRequest, "user_email_invalid")
|
||||
return
|
||||
}
|
||||
|
||||
currentID := userIDFromSession(c)
|
||||
isSelf := user.ID == currentID
|
||||
|
||||
// Self-protection: cannot disable/lock your own account.
|
||||
// 最后管理员检查必须在 user.Role 被新值覆盖之前基于原值判定。
|
||||
wasAdmin := user.Role == models.RoleAdmin
|
||||
|
||||
// 自我保护:不能禁用/锁定自己的账户。
|
||||
if isSelf && f.Status != models.StatusNormal {
|
||||
f.DisplayName = user.DisplayName
|
||||
f.Email = user.Email
|
||||
f.Gender = user.Gender
|
||||
f.Role = user.Role
|
||||
f.Status = user.Status
|
||||
if user.Birthday != nil {
|
||||
f.Birthday = user.Birthday.Format("2006-01-02")
|
||||
}
|
||||
renderUserForm(c, f, tr["user_cannot_disable_self"])
|
||||
APIError(c, http.StatusForbidden, "user_cannot_disable_self")
|
||||
return
|
||||
}
|
||||
|
||||
// Self-protection: cannot demote the last remaining admin.
|
||||
if user.Role == models.RoleAdmin && f.Role != models.RoleAdmin {
|
||||
var adminCount int64
|
||||
db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&adminCount)
|
||||
if adminCount <= 1 {
|
||||
c.Redirect(http.StatusFound, "/admin/users?error=last_admin")
|
||||
return
|
||||
}
|
||||
}
|
||||
if f.Role == "" {
|
||||
f.Role = user.Role
|
||||
}
|
||||
@@ -335,51 +398,82 @@ func UserUpdate(db *gorm.DB) gin.HandlerFunc {
|
||||
user.Birthday = nil
|
||||
}
|
||||
|
||||
// Optional password reset (leave blank to keep current).
|
||||
// 可选密码重置(留空表示保持当前密码)。
|
||||
if f.Password != "" {
|
||||
if err := user.SetPassword(f.Password); err != nil {
|
||||
renderUserForm(c, f, tr["article_error"])
|
||||
APIError(c, http.StatusInternalServerError, "article_error")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Save(&user).Error; err != nil {
|
||||
renderUserForm(c, f, tr["article_error"])
|
||||
// 自我保护:不能降级最后一位管理员(SECURITY_TODO #30:
|
||||
// 检查与写入在事务内原子执行,消除 TOCTOU 竞态)。
|
||||
lastAdminCheck := wasAdmin && f.Role != models.RoleAdmin
|
||||
lastAdminMu.Lock()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if lastAdminCheck {
|
||||
if err := ensureNotLastAdmin(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Save(&user).Error
|
||||
})
|
||||
lastAdminMu.Unlock()
|
||||
if err != nil {
|
||||
if errors.Is(err, errLastAdminRemoval) {
|
||||
APIError(c, http.StatusForbidden, "user_cannot_remove_last_admin")
|
||||
return
|
||||
}
|
||||
APIError(c, http.StatusInternalServerError, "article_error")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/users?saved=1&msg=updated")
|
||||
APIOK(c, "/admin/users?saved=1&msg=updated", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// UserDelete soft-deletes a user, with self-protection and last-admin guards.
|
||||
// UserDelete 软删除用户,带自我保护和最后管理员防线。
|
||||
func UserDelete(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
targetID := uintFormID(id)
|
||||
// SECURITY (#19):在触碰 GORM 前拒绝非数值 id(参见 UserEditPage)。
|
||||
targetID := uintFormID(c.Param("id"))
|
||||
if targetID == 0 {
|
||||
APIError(c, http.StatusBadRequest, "api_invalid_request")
|
||||
return
|
||||
}
|
||||
currentID := userIDFromSession(c)
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, id).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
if err := db.First(&user, targetID).Error; err != nil {
|
||||
APIError(c, http.StatusNotFound, "user_not_found")
|
||||
return
|
||||
}
|
||||
|
||||
// Cannot delete yourself.
|
||||
// 不能删除自己。
|
||||
if targetID == currentID {
|
||||
c.Redirect(http.StatusFound, "/admin/users?error=self_disable")
|
||||
APIError(c, http.StatusForbidden, "user_cannot_disable_self")
|
||||
return
|
||||
}
|
||||
// Cannot delete the last admin.
|
||||
if user.Role == models.RoleAdmin {
|
||||
var adminCount int64
|
||||
db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&adminCount)
|
||||
if adminCount <= 1 {
|
||||
c.Redirect(http.StatusFound, "/admin/users?error=last_admin")
|
||||
// 不能删除最后一位管理员(SECURITY_TODO #30:
|
||||
// 检查与写入在事务内原子执行,消除 TOCTOU 竞态)。
|
||||
lastAdminCheck := user.Role == models.RoleAdmin
|
||||
lastAdminMu.Lock()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if lastAdminCheck {
|
||||
if err := ensureNotLastAdmin(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Delete(&user).Error
|
||||
})
|
||||
lastAdminMu.Unlock()
|
||||
if err != nil {
|
||||
if errors.Is(err, errLastAdminRemoval) {
|
||||
APIError(c, http.StatusForbidden, "user_cannot_remove_last_admin")
|
||||
return
|
||||
}
|
||||
APIError(c, http.StatusInternalServerError, "article_error")
|
||||
return
|
||||
}
|
||||
|
||||
db.Delete(&user)
|
||||
c.Redirect(http.StatusFound, "/admin/users?saved=1&msg=deleted")
|
||||
APIOK(c, "/admin/users?saved=1&msg=deleted", nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// APIOK 返回统一成功响应。
|
||||
// redirect 非空时附带相对跳转地址(原 302 目标,可含 ?saved=1 等 query);
|
||||
// data 额外字段会合并进响应。
|
||||
func APIOK(c *gin.Context, redirect string, data gin.H) {
|
||||
resp := gin.H{"ok": true}
|
||||
if redirect != "" {
|
||||
resp["redirect"] = redirect
|
||||
}
|
||||
for k, v := range data {
|
||||
resp[k] = v
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// APIError 返回统一错误响应:{ok:false, code, error}。
|
||||
// trKey 是 i18n 键,直接作为 code 返回;error 为该键按请求语言的翻译文案。
|
||||
// trKey 为空时回退到通用键 "api_error"。
|
||||
func APIError(c *gin.Context, status int, trKey string) {
|
||||
tr := getTr(c)
|
||||
code := trKey
|
||||
if code == "" {
|
||||
code = "api_error"
|
||||
}
|
||||
msg := tr[code]
|
||||
if msg == "" {
|
||||
msg = tr["api_error"]
|
||||
}
|
||||
c.JSON(status, gin.H{
|
||||
"ok": false,
|
||||
"code": code,
|
||||
"error": msg,
|
||||
})
|
||||
}
|
||||
|
||||
// APIErrorf 同 APIError,但 i18n 文案可按 fmt.Sprintf 格式化
|
||||
// (针对含 %d/%s 占位符的键,如 comments_too_long)。
|
||||
func APIErrorf(c *gin.Context, status int, trKey string, args ...interface{}) {
|
||||
tr := getTr(c)
|
||||
msg := tr[trKey]
|
||||
if msg == "" {
|
||||
msg = tr["api_error"]
|
||||
} else if len(args) > 0 {
|
||||
msg = fmt.Sprintf(msg, args...)
|
||||
}
|
||||
c.JSON(status, gin.H{
|
||||
"ok": false,
|
||||
"code": trKey,
|
||||
"error": msg,
|
||||
})
|
||||
}
|
||||
|
||||
// bindJSON 将 JSON 请求体绑定到 v。
|
||||
// 绑定失败时返回 400 + api_invalid_request,并返回 false;
|
||||
// 请求体超出 BodyLimit 中间件设置的上限时返回 413 + request_too_large
|
||||
// (SECURITY_TODO #26)。使用前必须保证请求是 JSON。
|
||||
func bindJSON(c *gin.Context, v interface{}) bool {
|
||||
if err := c.ShouldBindJSON(v); err != nil {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
APIError(c, http.StatusRequestEntityTooLarge, "request_too_large")
|
||||
return false
|
||||
}
|
||||
APIError(c, http.StatusBadRequest, "api_invalid_request")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// --- 认证中间件(/api 前缀) ---
|
||||
|
||||
// TestAPIAuthRequiredReturnsJSON 断言 /api 路由未登录时返回 401 JSON
|
||||
// 而非 302 重定向(页面路由保持原行为)。
|
||||
func TestAPIAuthRequiredReturnsJSON(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
|
||||
cases := []struct {
|
||||
method string
|
||||
path string
|
||||
body gin.H
|
||||
}{
|
||||
{http.MethodPost, "/api/admin/articles", gin.H{"title": "x", "content": "y"}},
|
||||
{http.MethodPut, "/api/my/articles/1", gin.H{"title": "x"}},
|
||||
{http.MethodPost, "/api/profile", gin.H{"display_name": "x"}},
|
||||
{http.MethodDelete, "/api/my/articles/1", nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
// 模拟真实前端:匿名会话已持有 CSRF 令牌(CSRF 中间件先于
|
||||
// AuthRequired 校验,无 token 的请求会先得到 403)。
|
||||
anonCookie, w := e.anonSession()
|
||||
token := e.csrfTokenFrom(t, w)
|
||||
w = postJSON(e, tc.method, tc.path, anonCookie, token, tc.body)
|
||||
if w.Code != http.StatusUnauthorized || respCode(w) != "api_unauthorized" {
|
||||
t.Fatalf("%s %s: status=%d code=%q body=%s, want 401/api_unauthorized",
|
||||
tc.method, tc.path, w.Code, respCode(w), w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIAdminRequiredReturnsJSON 断言非管理员访问 /api/admin 返回 403 JSON。
|
||||
func TestAPIAdminRequiredReturnsJSON(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
alice := e.login(t, "alice")
|
||||
token := e.csrfTokenFor(t, alice)
|
||||
|
||||
w := postJSON(e, http.MethodPost, "/api/admin/users", alice, token,
|
||||
gin.H{"username": "x", "password": "longenough"})
|
||||
if w.Code != http.StatusForbidden || respCode(w) != "api_forbidden" {
|
||||
t.Fatalf("admin api: status=%d code=%q, want 403/api_forbidden", w.Code, respCode(w))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPICSRFHeaderRequired 断言 /api POST 缺少 CSRF 头时被中间件拒绝(403)。
|
||||
func TestAPICSRFHeaderRequired(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
w := e.do(http.MethodPost, "/api/auth/login", "", nil, "application/json")
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("csrf-less API POST: status=%d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 认证端点 ---
|
||||
|
||||
// TestAPILoginRoleRedirect 断言登录成功按角色返回 redirect。
|
||||
func TestAPILoginRoleRedirect(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
|
||||
// 管理员 → /admin
|
||||
w := e.loginRequest(t, "admin", "pw-admin")
|
||||
if w == nil {
|
||||
t.Fatal("admin login failed")
|
||||
}
|
||||
if respRedirect(w) != "/admin" {
|
||||
t.Fatalf("admin redirect=%q, want /admin", respRedirect(w))
|
||||
}
|
||||
|
||||
// 普通用户 → /
|
||||
w = e.loginRequest(t, "alice", "pw-alice")
|
||||
if respRedirect(w) != "/" {
|
||||
t.Fatalf("author redirect=%q, want /", respRedirect(w))
|
||||
}
|
||||
}
|
||||
|
||||
// --- 文章 CRUD API ---
|
||||
|
||||
// TestAPIArticleCRUD 覆盖 admin 创建/更新/软删除文章的完整链路。
|
||||
func TestAPIArticleCRUD(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
admin := e.login(t, "admin")
|
||||
token := e.csrfTokenFor(t, admin)
|
||||
|
||||
// 创建(草稿)。
|
||||
create := gin.H{"title": "api post", "status": "0", "content": "# hi"}
|
||||
w := postJSON(e, http.MethodPost, "/api/admin/articles", admin, token, create)
|
||||
if w.Code != http.StatusOK || !respOK(w) {
|
||||
t.Fatalf("create: status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var article models.Article
|
||||
if err := e.db.Where("title = ?", "api post").First(&article).Error; err != nil {
|
||||
t.Fatalf("load created article: %v", err)
|
||||
}
|
||||
if article.Slug == "" {
|
||||
t.Fatal("created article has empty slug")
|
||||
}
|
||||
|
||||
// 更新。
|
||||
update := gin.H{"title": "api post v2", "content": "changed", "status": "1"}
|
||||
w = postJSON(e, http.MethodPut, "/api/admin/articles/"+itoa(article.ID), admin, token, update)
|
||||
if w.Code != http.StatusOK || !respOK(w) {
|
||||
t.Fatalf("update: status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := e.db.First(&article, article.ID).Error; err != nil {
|
||||
t.Fatalf("reload article: %v", err)
|
||||
}
|
||||
if article.Title != "api post v2" || article.Status != models.ArticlePublished {
|
||||
t.Fatalf("update not applied: title=%q status=%d", article.Title, article.Status)
|
||||
}
|
||||
|
||||
// 校验错误:缺标题 → 400。
|
||||
w = postJSON(e, http.MethodPost, "/api/admin/articles", admin, token, gin.H{"content": "x"})
|
||||
if w.Code != http.StatusBadRequest || respCode(w) != "article_title_required" {
|
||||
t.Fatalf("missing title: status=%d code=%q", w.Code, respCode(w))
|
||||
}
|
||||
|
||||
// 删除(软删除)。
|
||||
w = postJSON(e, http.MethodDelete, "/api/admin/articles/"+itoa(article.ID), admin, token, nil)
|
||||
if w.Code != http.StatusOK || !respOK(w) {
|
||||
t.Fatalf("delete: status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var count int64
|
||||
e.db.Unscoped().Model(&models.Article{}).Where("id = ?", article.ID).Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatal("article row missing after soft delete")
|
||||
}
|
||||
}
|
||||
|
||||
// --- 我的文章 API ---
|
||||
|
||||
// TestAPIMyArticlesOwnership 断言普通用户只能创建/更新/删除自己的文章。
|
||||
func TestAPIMyArticlesOwnership(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
alice := e.login(t, "alice")
|
||||
token := e.csrfTokenFor(t, alice)
|
||||
bob := e.login(t, "bob")
|
||||
bobToken := e.csrfTokenFor(t, bob)
|
||||
|
||||
bobArt := models.Article{}
|
||||
if err := e.db.Where("slug = ?", "bob-post").First(&bobArt).Error; err != nil {
|
||||
t.Fatalf("load bob article: %v", err)
|
||||
}
|
||||
|
||||
// bob 不能更新 alice 的文章 → 404。
|
||||
aliceArt := models.Article{}
|
||||
if err := e.db.Where("slug = ?", "alice-post").First(&aliceArt).Error; err != nil {
|
||||
t.Fatalf("load alice article: %v", err)
|
||||
}
|
||||
w := postJSON(e, http.MethodPut, "/api/my/articles/"+itoa(aliceArt.ID), bob, bobToken,
|
||||
gin.H{"title": "hijacked"})
|
||||
if w.Code != http.StatusNotFound || respCode(w) != "article_not_found" {
|
||||
t.Fatalf("cross-owner update: status=%d code=%q, want 404/article_not_found", w.Code, respCode(w))
|
||||
}
|
||||
w = postJSON(e, http.MethodDelete, "/api/my/articles/"+itoa(aliceArt.ID), bob, bobToken, nil)
|
||||
if w.Code != http.StatusOK || !respOK(w) {
|
||||
t.Fatalf("cross-owner delete: status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
// 删除只影响自己的行:bob 的删除目标仍是 alice 行 —— author_id 过滤
|
||||
// 使 DELETE 命中 0 行,实际上等价于无操作;验证 alice 文章仍在。
|
||||
var count int64
|
||||
e.db.Model(&models.Article{}).Where("slug = ?", "alice-post").Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatal("alice article affected by bob's delete")
|
||||
}
|
||||
|
||||
// alice 创建自己的文章。
|
||||
w = postJSON(e, http.MethodPost, "/api/my/articles", alice, token,
|
||||
gin.H{"title": "my post", "content": "hi"})
|
||||
if w.Code != http.StatusOK || respRedirect(w) != "/my/articles" {
|
||||
t.Fatalf("my create: status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- 评论 API ---
|
||||
|
||||
// TestAPICommentValidationCodes 断言评论校验错误带语义化 code。
|
||||
func TestAPICommentValidationCodes(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
anonCookie, w := e.anonSession()
|
||||
token := e.csrfTokenFrom(t, w)
|
||||
|
||||
w = postJSON(e, http.MethodPost, "/api/article/alice-post/comments", anonCookie, token, gin.H{})
|
||||
if w.Code != http.StatusBadRequest || respCode(w) != "comments_required_name" {
|
||||
t.Fatalf("empty comment: status=%d code=%q", w.Code, respCode(w))
|
||||
}
|
||||
|
||||
w = postJSON(e, http.MethodPost, "/api/article/alice-post/comments", anonCookie, token, gin.H{
|
||||
"name": "x", "email": "x@example.com", "content": "ok",
|
||||
})
|
||||
if w.Code != http.StatusOK || !respOK(w) {
|
||||
t.Fatalf("valid comment: status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if respRedirect(w) != "/article/alice-post#comment-1" {
|
||||
t.Fatalf("comment redirect=%q", respRedirect(w))
|
||||
}
|
||||
}
|
||||
|
||||
// --- 注册 API ---
|
||||
|
||||
// TestAPIRegisterConflictAndMismatch 断言用户名冲突 409、密码不一致 400。
|
||||
func TestAPIRegisterConflictAndMismatch(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
if err := e.db.Model(&models.SiteSetting{}).Where("id = ?", 1).Update("allow_registration", true).Error; err != nil {
|
||||
t.Fatalf("enable registration: %v", err)
|
||||
}
|
||||
// 匿名会话 + CSRF。
|
||||
req, w := e.anonSession()
|
||||
token := e.csrfTokenFrom(t, w)
|
||||
|
||||
body := gin.H{
|
||||
"username": "alice",
|
||||
"password": "secret1",
|
||||
"confirm_password": "secret1",
|
||||
}
|
||||
ww := postJSON(e, http.MethodPost, "/api/auth/register", req, token, body)
|
||||
if ww.Code != http.StatusConflict || respCode(ww) != "user_username_exists" {
|
||||
t.Fatalf("duplicate username: status=%d code=%q, want 409/user_username_exists",
|
||||
ww.Code, respCode(ww))
|
||||
}
|
||||
|
||||
body["username"] = "newuser"
|
||||
body["confirm_password"] = "different"
|
||||
ww = postJSON(e, http.MethodPost, "/api/auth/register", req, token, body)
|
||||
if ww.Code != http.StatusBadRequest || respCode(ww) != "register_password_mismatch" {
|
||||
t.Fatalf("password mismatch: status=%d code=%q", ww.Code, respCode(ww))
|
||||
}
|
||||
}
|
||||
+144
-123
@@ -17,34 +17,33 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// slugSepRe matches runs of non-word characters (anything that is not a
|
||||
// letter or digit); these become single dashes.
|
||||
// slugSepRe 匹配连续的非单词字符(任何非字母或数字的字符);
|
||||
// 这些字符会被替换为单个连字符。
|
||||
var slugSepRe = regexp.MustCompile(`[^\p{L}\p{N}]+`)
|
||||
|
||||
// slugDashRe matches runs of dashes to collapse.
|
||||
// slugDashRe 匹配连续连字符,用于折叠。
|
||||
var slugDashRe = regexp.MustCompile(`-{2,}`)
|
||||
|
||||
// slugAsciiRe matches a slug made only of URL-safe ASCII letters, digits and
|
||||
// dashes. Slugs containing other characters (e.g. CJK, or Unicode lowercase
|
||||
// quirks like the Turkish dotless i) are not URL-stable and are discarded in
|
||||
// favor of a "post-<id>" fallback.
|
||||
// slugAsciiRe 匹配仅由 URL 安全的 ASCII 字母、数字和连字符组成的 slug。
|
||||
// 包含其他字符的 slug(如 CJK,或类似土耳其无点 i 的 Unicode 小写
|
||||
// 癖好)在 URL 中不稳定,会被丢弃,转而使用 "post-<id>" 回退方案。
|
||||
var slugAsciiRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||||
|
||||
// randomToken returns a 32-byte hex token used to own pending attachments on
|
||||
// the article-create page until the article is saved.
|
||||
// randomToken 返回 32 字节十六进制令牌,用于在文章保存前于创建页面上
|
||||
// 持有待处理的附件归属。
|
||||
func randomToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// Extremely unlikely; fall back to a time-based value.
|
||||
// 极罕见;回退到基于时间的值。
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 16)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// generateSlug converts a title into a URL-friendly ASCII slug. Returns "" when
|
||||
// the title yields no URL-safe ASCII characters (the caller must fall back,
|
||||
// e.g. to "post-<id>"). Non-ASCII letters are intentionally dropped rather
|
||||
// than kept, because raw CJK in a URL slug is not stable.
|
||||
// generateSlug 将标题转换为 URL 友好的 ASCII slug。
|
||||
// 当标题无法产生任何 URL 安全的 ASCII 字符时返回 ""(调用方必须回退,
|
||||
// 例如使用 "post-<id>")。非 ASCII 字母会被有意丢弃而非保留,
|
||||
// 因为 URL slug 中的原始 CJK 字符不稳定。
|
||||
func generateSlug(title string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(title))
|
||||
s = slugSepRe.ReplaceAllString(s, "-")
|
||||
@@ -56,9 +55,9 @@ func generateSlug(title string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// fallbackSlug returns a non-empty slug when title-derived slugs are empty
|
||||
// (e.g. a title of only punctuation/whitespace, or all-stripped). Uses the
|
||||
// article ID when available, else a short token.
|
||||
// fallbackSlug 在由标题派生的 slug 为空时返回非空 slug
|
||||
// (例如标题只包含标点/空白,或全部被剔除)。可用时使用文章 ID,
|
||||
// 否则使用短令牌。
|
||||
func fallbackSlug(id uint) string {
|
||||
if id > 0 {
|
||||
return fmt.Sprintf("post-%d", id)
|
||||
@@ -66,41 +65,44 @@ func fallbackSlug(id uint) string {
|
||||
return "post-" + randomToken()[:8]
|
||||
}
|
||||
|
||||
// articleForm holds the parsed article form fields, shared by the create and
|
||||
// edit handlers and their validation-error repopulation paths.
|
||||
// articleForm 是文章创建/更新接口的 JSON 请求体。
|
||||
// Action/TitleText/ArticleID 仅由页面渲染路径使用,不参与 JSON 绑定。
|
||||
type articleForm struct {
|
||||
Title string
|
||||
Slug string
|
||||
Summary string
|
||||
Content string
|
||||
Cover string
|
||||
StatusStr string
|
||||
IsTop bool
|
||||
PublishedAt string // datetime-local format: "2006-01-02T15:04"
|
||||
Tags string // comma-separated tag names
|
||||
Action string // form action URL
|
||||
TitleText string // page heading text (create vs edit)
|
||||
ArticleID uint // existing article ID (edit page); 0 on create
|
||||
SessionToken string // pending-attachment ownership token (create page)
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content"`
|
||||
Cover string `json:"cover"`
|
||||
StatusStr string `json:"status"` // "0" 草稿、"1" 已发布
|
||||
IsTop bool `json:"is_top"`
|
||||
PublishedAt string `json:"published_at"` // datetime-local:"2006-01-02T15:04"
|
||||
Tags string `json:"tags"` // 逗号分隔的标签名
|
||||
SessionToken string `json:"session_token"`
|
||||
Action string // 表单提交 URL(页面渲染用)
|
||||
TitleText string // 页面标题文字(创建还是编辑)
|
||||
ArticleID uint // 已有文章 ID(编辑页面);创建时为 0
|
||||
}
|
||||
|
||||
// parseArticleForm reads and trims the article form fields from the request.
|
||||
func parseArticleForm(c *gin.Context) articleForm {
|
||||
return articleForm{
|
||||
Title: strings.TrimSpace(c.PostForm("title")),
|
||||
Slug: strings.TrimSpace(c.PostForm("slug")),
|
||||
Summary: strings.TrimSpace(c.PostForm("summary")),
|
||||
Content: strings.TrimSpace(c.PostForm("content")),
|
||||
Cover: strings.TrimSpace(c.PostForm("cover")),
|
||||
StatusStr: c.PostForm("status"),
|
||||
IsTop: c.PostForm("is_top") == "1",
|
||||
PublishedAt: strings.TrimSpace(c.PostForm("published_at")),
|
||||
Tags: strings.TrimSpace(c.PostForm("tags")),
|
||||
// parseArticleFormJSON 绑定 JSON 请求体并去除空白后的文章字段。
|
||||
// 绑定失败时已写入 400 响应并返回 ok=false。
|
||||
func parseArticleFormJSON(c *gin.Context) (articleForm, bool) {
|
||||
var f articleForm
|
||||
if !bindJSON(c, &f) {
|
||||
return f, false
|
||||
}
|
||||
f.Title = strings.TrimSpace(f.Title)
|
||||
f.Slug = strings.TrimSpace(f.Slug)
|
||||
f.Summary = strings.TrimSpace(f.Summary)
|
||||
f.Content = strings.TrimSpace(f.Content)
|
||||
f.Cover = strings.TrimSpace(f.Cover)
|
||||
f.PublishedAt = strings.TrimSpace(f.PublishedAt)
|
||||
f.Tags = strings.TrimSpace(f.Tags)
|
||||
f.SessionToken = strings.TrimSpace(f.SessionToken)
|
||||
return f, true
|
||||
}
|
||||
|
||||
// applyFormToData writes the form field values into the template data map so
|
||||
// the form is repopulated on render (initial load or validation error).
|
||||
// applyFormToData 将表单字段值写入模板数据映射,使渲染时表单被重新填充
|
||||
// (初次加载或校验错误)。
|
||||
func applyFormToData(data gin.H, f articleForm) {
|
||||
data["FormTitle"] = f.Title
|
||||
data["FormSlug"] = f.Slug
|
||||
@@ -117,8 +119,7 @@ func applyFormToData(data gin.H, f articleForm) {
|
||||
data["SessionToken"] = f.SessionToken
|
||||
}
|
||||
|
||||
// renderArticleForm renders the shared article form template with the given
|
||||
// form values and optional error message.
|
||||
// renderArticleForm 使用给定的表单值和可选的错误消息渲染共享的文章表单模板。
|
||||
func renderArticleForm(c *gin.Context, db *gorm.DB, f articleForm, errMsg string) {
|
||||
data := DefaultData(c)
|
||||
data["Title"] = f.TitleText
|
||||
@@ -129,8 +130,8 @@ func renderArticleForm(c *gin.Context, db *gorm.DB, f articleForm, errMsg string
|
||||
c.HTML(http.StatusOK, "article_create", data)
|
||||
}
|
||||
|
||||
// sessionAuthorID extracts the logged-in user's ID from the session, defending
|
||||
// against int/uint/int64/float64 storage. Returns ok=false if absent.
|
||||
// sessionAuthorID 从会话中提取已登录用户的 ID,兼容 int/uint/int64/float64
|
||||
// 存储类型。不存在时返回 ok=false。
|
||||
func sessionAuthorID(c *gin.Context) (uint, bool) {
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
@@ -151,8 +152,8 @@ func sessionAuthorID(c *gin.Context) (uint, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// statusFromForm parses the status string ("0" draft, "1" published) and
|
||||
// returns the model status constant, defaulting to draft.
|
||||
// statusFromForm 解析状态字符串("0" 草稿、"1" 已发布),
|
||||
// 返回模型状态常量,默认为草稿。
|
||||
func statusFromForm(statusStr string) int {
|
||||
if statusStr == "1" {
|
||||
return models.ArticlePublished
|
||||
@@ -160,13 +161,13 @@ func statusFromForm(statusStr string) int {
|
||||
return models.ArticleDraft
|
||||
}
|
||||
|
||||
// parsePublishedAt parses the datetime-local format ("2006-01-02T15:04") from
|
||||
// the form into a time.Time pointer. Returns nil if the string is empty or invalid.
|
||||
// parsePublishedAt 将表单中的 datetime-local 格式("2006-01-02T15:04")解析为
|
||||
// time.Time 指针。字符串为空或非法时返回 nil。
|
||||
func parsePublishedAt(publishedAtStr string) *time.Time {
|
||||
if publishedAtStr == "" {
|
||||
return nil
|
||||
}
|
||||
// datetime-local format: "2006-01-02T15:04"
|
||||
// datetime-local 格式:"2006-01-02T15:04"
|
||||
t, err := time.ParseInLocation("2006-01-02T15:04", publishedAtStr, time.Local)
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -174,8 +175,8 @@ func parsePublishedAt(publishedAtStr string) *time.Time {
|
||||
return &t
|
||||
}
|
||||
|
||||
// formatPublishedAt formats a time.Time pointer into datetime-local format for the form.
|
||||
// Returns empty string if the pointer is nil.
|
||||
// formatPublishedAt 将 time.Time 指针格式化为表单所需的 datetime-local 格式。
|
||||
// 指针为 nil 时返回空字符串。
|
||||
func formatPublishedAt(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
@@ -183,7 +184,7 @@ func formatPublishedAt(t *time.Time) string {
|
||||
return t.Local().Format("2006-01-02T15:04")
|
||||
}
|
||||
|
||||
// parseTags splits comma-separated tag string and returns tag names.
|
||||
// parseTags 按逗号拆分标签字符串并返回标签名。
|
||||
func parseTags(tagStr string) []string {
|
||||
if tagStr == "" {
|
||||
return []string{}
|
||||
@@ -199,19 +200,19 @@ func parseTags(tagStr string) []string {
|
||||
return tags
|
||||
}
|
||||
|
||||
// syncArticleTags associates tags with an article (find or create tags).
|
||||
// syncArticleTags 将标签与文章关联(查找或创建标签)。
|
||||
func syncArticleTags(db *gorm.DB, article *models.Article, tagNames []string) error {
|
||||
// Clear existing tags
|
||||
// 清除现有标签
|
||||
if err := db.Model(article).Association("Tags").Clear(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If no tags, we're done
|
||||
// 若没有标签,则完成
|
||||
if len(tagNames) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find or create each tag and associate with article
|
||||
// 查找或创建每个标签并关联到文章
|
||||
var tags []models.Tag
|
||||
for _, name := range tagNames {
|
||||
tag, err := models.FindOrCreateTag(db, name, name)
|
||||
@@ -223,14 +224,14 @@ func syncArticleTags(db *gorm.DB, article *models.Article, tagNames []string) er
|
||||
}
|
||||
}
|
||||
|
||||
// Associate tags with article
|
||||
// 将标签关联到文章
|
||||
if len(tags) > 0 {
|
||||
if err := db.Model(article).Association("Tags").Append(tags); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Update tag counts
|
||||
// 更新标签计数
|
||||
for _, tag := range tags {
|
||||
models.UpdateTagCount(db, tag.ID)
|
||||
}
|
||||
@@ -238,7 +239,7 @@ func syncArticleTags(db *gorm.DB, article *models.Article, tagNames []string) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatArticleTags converts article tags to comma-separated string for form.
|
||||
// formatArticleTags 将文章标签转换为逗号分隔的字符串以填充表单。
|
||||
func formatArticleTags(tags []models.Tag) string {
|
||||
if len(tags) == 0 {
|
||||
return ""
|
||||
@@ -250,7 +251,7 @@ func formatArticleTags(tags []models.Tag) string {
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// ArticleCreatePage renders the article creation form.
|
||||
// ArticleCreatePage 渲染文章创建表单。
|
||||
func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
@@ -263,30 +264,36 @@ func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// ArticleCreate handles the POST request to create a new article.
|
||||
func ArticleCreate(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
f := parseArticleForm(c)
|
||||
f.Action = "/admin/articles/new"
|
||||
f.TitleText = tr["article_create_title"]
|
||||
f.SessionToken = strings.TrimSpace(c.PostForm("session_token"))
|
||||
// ArticleCreate 处理创建新文章的 POST 请求(admin 与 my 共用)。
|
||||
// redirectPath 是成功跳转目标(admin 用 /admin,普通用户用 /my/articles)。
|
||||
func ArticleCreate(db *gorm.DB, redirectPath string) gin.HandlerFunc {
|
||||
return articleCreate(db, redirectPath, true)
|
||||
}
|
||||
|
||||
// Validate required fields.
|
||||
// articleCreate 实现文章创建逻辑。allowIsTop=false 时普通作者的置顶请求
|
||||
// 被降级为 false(SECURITY_TODO #31:置顶全站为管理能力,仅 admin 路径可设)。
|
||||
func articleCreate(db *gorm.DB, redirectPath string, allowIsTop bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
f, ok := parseArticleFormJSON(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// 校验必填字段。
|
||||
if f.Title == "" {
|
||||
renderArticleForm(c, db, f, tr["article_title_required"])
|
||||
APIError(c, http.StatusBadRequest, "article_title_required")
|
||||
return
|
||||
}
|
||||
if f.Content == "" {
|
||||
renderArticleForm(c, db, f, tr["article_content_required"])
|
||||
APIError(c, http.StatusBadRequest, "article_content_required")
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-generate slug if empty.
|
||||
// 为空时自动生成 slug。
|
||||
if f.Slug == "" {
|
||||
f.Slug = generateSlug(f.Title)
|
||||
// Title had no usable characters (e.g. only punctuation). Use a
|
||||
// temporary token-based slug now; refine to post-<id> after insert.
|
||||
// 标题没有可用字符(例如只有标点)。先使用临时的基于令牌的
|
||||
// slug;插入后再完善为 post-<id>。
|
||||
if f.Slug == "" {
|
||||
f.Slug = fallbackSlug(0)
|
||||
}
|
||||
@@ -296,16 +303,16 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc {
|
||||
|
||||
authorID, ok := sessionAuthorID(c)
|
||||
if !ok {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
APIError(c, http.StatusUnauthorized, "api_unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var publishedAt *time.Time
|
||||
if f.PublishedAt != "" {
|
||||
// User provided a custom published time
|
||||
// 用户提供了自定义发布时间
|
||||
publishedAt = parsePublishedAt(f.PublishedAt)
|
||||
} else if status == models.ArticlePublished {
|
||||
// Auto-stamp with current time if publishing without custom time
|
||||
// 未提供自定义时间而发布时,自动盖上当前时间
|
||||
now := time.Now()
|
||||
publishedAt = &now
|
||||
}
|
||||
@@ -318,41 +325,41 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc {
|
||||
Content: f.Content,
|
||||
Cover: f.Cover,
|
||||
Status: status,
|
||||
IsTop: f.IsTop,
|
||||
IsTop: f.IsTop && allowIsTop,
|
||||
PublishedAt: publishedAt,
|
||||
}
|
||||
|
||||
if err := db.Create(&article).Error; err != nil {
|
||||
renderArticleForm(c, db, f, tr["article_error"])
|
||||
APIError(c, http.StatusInternalServerError, "article_error")
|
||||
return
|
||||
}
|
||||
|
||||
// Refine a token-based placeholder slug to the readable post-<id> form.
|
||||
// 将基于令牌的占位 slug 完善为可读的 post-<id> 形式。
|
||||
if strings.HasPrefix(f.Slug, "post-") && len(f.Slug) > 9 {
|
||||
if newSlug := fallbackSlug(article.ID); newSlug != "" {
|
||||
db.Model(&article).Update("slug", newSlug)
|
||||
}
|
||||
}
|
||||
|
||||
// Sync article tags
|
||||
// 同步文章标签
|
||||
tagNames := parseTags(f.Tags)
|
||||
if err := syncArticleTags(db, &article, tagNames); err != nil {
|
||||
// Log error but don't fail the article creation
|
||||
// The article is already created, tags are optional
|
||||
// 记录错误但不影响文章创建
|
||||
// 文章已经创建,标签是可选内容
|
||||
}
|
||||
|
||||
// Bind any attachments uploaded during creation (plan A: pending rows
|
||||
// owned by session_token, article_id=0).
|
||||
// 绑定创建期间上传的任何附件(方案 A:由 session_token 持有、
|
||||
// article_id=0 的待处理行)。
|
||||
if f.SessionToken != "" {
|
||||
_ = BindPendingAttachments(db, f.SessionToken, article.ID)
|
||||
}
|
||||
|
||||
// Success: redirect to dashboard.
|
||||
c.Redirect(http.StatusFound, "/admin")
|
||||
// 成功:跳转到文章列表。
|
||||
APIOK(c, redirectPath, gin.H{"article_id": article.ID})
|
||||
}
|
||||
}
|
||||
|
||||
// ArticleListPage renders the admin article management list.
|
||||
// ArticleListPage 渲染后台文章管理列表。
|
||||
func ArticleListPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
@@ -365,7 +372,7 @@ func ArticleListPage(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// ArticleEditPage renders the shared form prefilled with an existing article.
|
||||
// ArticleEditPage 渲染预填现有文章的共享表单。
|
||||
func ArticleEditPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
@@ -377,7 +384,7 @@ func ArticleEditPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Preload tags for the article
|
||||
// 预加载文章标签
|
||||
db.Model(&article).Association("Tags").Find(&article.Tags)
|
||||
|
||||
renderArticleForm(c, db, articleForm{
|
||||
@@ -397,28 +404,39 @@ func ArticleEditPage(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// ArticleUpdate handles the POST request to update an existing article.
|
||||
func ArticleUpdate(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
id := c.Param("id")
|
||||
// ArticleUpdate 处理更新现有文章的请求(admin 与 my 共用)。
|
||||
// redirectPath 是成功跳转目标(admin 用 /admin/articles,普通用户用 /my/articles)。
|
||||
func ArticleUpdate(db *gorm.DB, redirectPath string) gin.HandlerFunc {
|
||||
return articleUpdate(db, redirectPath, true)
|
||||
}
|
||||
|
||||
var article models.Article
|
||||
if err := db.First(&article, "id = ?", id).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/articles")
|
||||
// articleUpdate 实现文章更新逻辑。allowIsTop=false 时普通作者提交的
|
||||
// 置顶值被忽略(SECURITY_TODO #31:仅 admin 路径可改置顶状态)。
|
||||
func articleUpdate(db *gorm.DB, redirectPath string, allowIsTop bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := parseUintParam(c, "id")
|
||||
if id == 0 {
|
||||
APIError(c, http.StatusBadRequest, "api_invalid_request")
|
||||
return
|
||||
}
|
||||
|
||||
f := parseArticleForm(c)
|
||||
f.Action = "/admin/articles/" + id + "/edit"
|
||||
f.TitleText = tr["article_edit_title"]
|
||||
var article models.Article
|
||||
if err := db.First(&article, id).Error; err != nil {
|
||||
APIError(c, http.StatusNotFound, "article_not_found")
|
||||
return
|
||||
}
|
||||
|
||||
f, ok := parseArticleFormJSON(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if f.Title == "" {
|
||||
renderArticleForm(c, db, f, tr["article_title_required"])
|
||||
APIError(c, http.StatusBadRequest, "article_title_required")
|
||||
return
|
||||
}
|
||||
if f.Content == "" {
|
||||
renderArticleForm(c, db, f, tr["article_content_required"])
|
||||
APIError(c, http.StatusBadRequest, "article_content_required")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -431,13 +449,13 @@ func ArticleUpdate(db *gorm.DB) gin.HandlerFunc {
|
||||
|
||||
newStatus := statusFromForm(f.StatusStr)
|
||||
|
||||
// Handle published_at: use form value if provided, otherwise auto-stamp on first publish.
|
||||
// 处理 published_at:若提供了表单值则使用,否则在首次发布时自动盖章。
|
||||
var publishedAt *time.Time
|
||||
if f.PublishedAt != "" {
|
||||
// User provided a custom published time
|
||||
// 用户提供了自定义发布时间
|
||||
publishedAt = parsePublishedAt(f.PublishedAt)
|
||||
} else {
|
||||
// Auto-stamp the publish time the first time an article is published.
|
||||
// 文章首次发布时自动盖上发布时间。
|
||||
wasPublished := article.Status == models.ArticlePublished
|
||||
publishedAt = article.PublishedAt
|
||||
if newStatus == models.ArticlePublished && !wasPublished && publishedAt == nil {
|
||||
@@ -453,31 +471,34 @@ func ArticleUpdate(db *gorm.DB) gin.HandlerFunc {
|
||||
"Content": f.Content,
|
||||
"Cover": f.Cover,
|
||||
"Status": newStatus,
|
||||
"IsTop": f.IsTop,
|
||||
"IsTop": f.IsTop && allowIsTop,
|
||||
"PublishedAt": publishedAt,
|
||||
}
|
||||
|
||||
if err := db.Model(&article).Updates(updates).Error; err != nil {
|
||||
renderArticleForm(c, db, f, tr["article_error"])
|
||||
APIError(c, http.StatusInternalServerError, "article_error")
|
||||
return
|
||||
}
|
||||
|
||||
// Sync article tags
|
||||
// 同步文章标签
|
||||
tagNames := parseTags(f.Tags)
|
||||
if err := syncArticleTags(db, &article, tagNames); err != nil {
|
||||
// Log error but don't fail the article update
|
||||
// 记录错误但不影响文章更新
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/admin/articles")
|
||||
APIOK(c, redirectPath, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// ArticleDelete soft-deletes an article (GORM fills DeletedAt) and redirects
|
||||
// back to the management list.
|
||||
func ArticleDelete(db *gorm.DB) gin.HandlerFunc {
|
||||
// ArticleDelete 软删除文章(GORM 填充 DeletedAt)。
|
||||
func ArticleDelete(db *gorm.DB, redirectPath string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
db.Delete(&models.Article{}, "id = ?", id)
|
||||
c.Redirect(http.StatusFound, "/admin/articles")
|
||||
id := parseUintParam(c, "id")
|
||||
if id == 0 {
|
||||
APIError(c, http.StatusBadRequest, "api_invalid_request")
|
||||
return
|
||||
}
|
||||
db.Delete(&models.Article{}, id)
|
||||
APIOK(c, redirectPath, nil)
|
||||
}
|
||||
}
|
||||
+106
-42
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -16,8 +17,7 @@ import (
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// attachmentsDir returns the on-disk directory for attachments under the given
|
||||
// storage path, honoring the configured storage_dir.
|
||||
// attachmentsDir 返回给定存储路径下附件的磁盘目录,遵循配置的 storage_dir。
|
||||
func attachmentsDir(storagePath string) string {
|
||||
dir := models.GetUploadConfig().StorageDir
|
||||
if dir == "" {
|
||||
@@ -26,8 +26,8 @@ func attachmentsDir(storagePath string) string {
|
||||
return filepath.Join(storagePath, dir)
|
||||
}
|
||||
|
||||
// attachmentRelPath returns the path of an attachment relative to the storage
|
||||
// root, e.g. "attachments/<stored>" — used to build /uploads URLs.
|
||||
// attachmentRelPath 返回附件相对于存储根目录的路径,
|
||||
// 例如 "attachments/<stored>"——用于构建 /uploads URL。
|
||||
func attachmentRelPath(stored string) string {
|
||||
dir := models.GetUploadConfig().StorageDir
|
||||
if dir == "" {
|
||||
@@ -36,9 +36,8 @@ func attachmentRelPath(stored string) string {
|
||||
return dir + "/" + stored
|
||||
}
|
||||
|
||||
// attachmentURL builds the public URL for an attachment: the default download
|
||||
// base URL (if configured) joined with the relative path, else the local
|
||||
// /uploads path served by the app.
|
||||
// attachmentURL 构建附件的公开 URL:默认下载基础 URL(若已配置)
|
||||
// 与相对路径拼接,否则使用应用提供的本地 /uploads 路径。
|
||||
func attachmentURL(stored string) string {
|
||||
rel := attachmentRelPath(stored)
|
||||
if base := models.DefaultDownloadBaseURL(); base != "" {
|
||||
@@ -47,12 +46,36 @@ func attachmentURL(stored string) string {
|
||||
return "/uploads/" + rel
|
||||
}
|
||||
|
||||
// ---------------- Upload ----------------
|
||||
// ---------------- 上传 ----------------
|
||||
|
||||
// UploadAttachment handles AJAX attachment uploads from the article create/edit
|
||||
// form. The request carries either a real article_id (edit page) or a
|
||||
// session_token (create page, pending binding). Files are content-addressed by
|
||||
// SHA-256 for on-disk deduplication.
|
||||
// currentUserIsAdmin 基于 SetUserContext 中间件填充的上下文,
|
||||
// 报告已认证用户是否具有管理员角色。
|
||||
func currentUserIsAdmin(c *gin.Context) bool {
|
||||
role, _ := c.Get("role")
|
||||
r, _ := role.(string)
|
||||
return r == models.RoleAdmin
|
||||
}
|
||||
|
||||
// canManageArticle 报告当前用户是否为给定文章附加文件(或管理其附件)的
|
||||
// 合法用户:管理员始终允许,否则仅允许文章作者。
|
||||
func canManageArticle(c *gin.Context, db *gorm.DB, articleID uint) bool {
|
||||
if currentUserIsAdmin(c) {
|
||||
return true
|
||||
}
|
||||
uid, ok := sessionAuthorID(c)
|
||||
if !ok || articleID == 0 {
|
||||
return false
|
||||
}
|
||||
var article models.Article
|
||||
if err := db.First(&article, "id = ?", articleID).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return article.AuthorID == uid
|
||||
}
|
||||
|
||||
// UploadAttachment 处理来自文章创建/编辑表单的 AJAX 附件上传。
|
||||
// 请求携带真实的 article_id(编辑页)或 session_token(创建页,待绑定)。
|
||||
// 文件按 SHA-256 内容寻址,实现磁盘去重。
|
||||
func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uploaderID, ok := sessionAuthorID(c)
|
||||
@@ -63,12 +86,18 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
|
||||
articleID := parseUintForm(c, "article_id")
|
||||
token := strings.TrimSpace(c.PostForm("session_token"))
|
||||
// On the create page the article does not exist yet; require a token.
|
||||
// 创建页面上文章尚不存在;要求提供令牌。
|
||||
if articleID == 0 && token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing session_token"})
|
||||
return
|
||||
}
|
||||
|
||||
// 所有权检查:非管理员只能附加到自己的文章。
|
||||
if articleID != 0 && !canManageArticle(c, db, articleID) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no file provided"})
|
||||
@@ -76,7 +105,7 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Validate against the platform upload policy (switch + type + size).
|
||||
// 依据平台上传策略校验(总开关 + 类型 + 大小)。
|
||||
check := ValidateUpload(header)
|
||||
if !check.OK {
|
||||
if !models.GetUploadConfig().Enabled {
|
||||
@@ -91,16 +120,24 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Read fully to compute the content hash.
|
||||
// 完整读取:用于内容哈希(去重)和魔数内容校验(#14)。
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
|
||||
return
|
||||
}
|
||||
|
||||
// SECURITY_TODO #14:文件字节必须与声明扩展名配置的 MIME 类型匹配
|
||||
//(按头部策略进行魔数校验)。名为 .txt 却携带 PNG 字节的文件将被拒绝。
|
||||
if !contentMatchesType(check.Type, content) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file content does not match its declared type"})
|
||||
return
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(content)
|
||||
stored := hex.EncodeToString(sum[:])
|
||||
|
||||
// Deduplicate on disk: only write when the file is absent.
|
||||
// 磁盘去重:仅在文件不存在时才写入。
|
||||
dir := attachmentsDir(storagePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create storage dir"})
|
||||
@@ -142,11 +179,11 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Delete ----------------
|
||||
// ---------------- 删除 ----------------
|
||||
|
||||
// DeleteAttachment soft-deletes an attachment record and removes the on-disk
|
||||
// file only when no remaining records reference it (reference counting, since
|
||||
// content-addressed files may be shared).
|
||||
// DeleteAttachment 软删除附件记录,仅当没有其余记录引用时才删除磁盘文件
|
||||
// (引用计数,因为内容寻址的文件可能被共享)。只有管理员、上传者或
|
||||
// 文件所在文章的作者可以删除。
|
||||
func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := parseUintParam(c, "id")
|
||||
@@ -155,6 +192,19 @@ func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 所有权检查:管理员、上传者或所属文章的作者。
|
||||
if !currentUserIsAdmin(c) {
|
||||
uid, ok := sessionAuthorID(c)
|
||||
owned := ok && att.UploaderID == uid
|
||||
if !owned && att.ArticleID != 0 {
|
||||
owned = canManageArticle(c, db, att.ArticleID)
|
||||
}
|
||||
if !owned {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
}
|
||||
stored := att.StoredName
|
||||
|
||||
if err := db.Delete(&att).Error; err != nil {
|
||||
@@ -162,23 +212,31 @@ func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Reference count: any other (non-deleted) rows pointing at this file?
|
||||
// 引用计数:还有任何其他(未删除)行指向此文件吗?
|
||||
var count int64
|
||||
db.Model(&models.Attachment{}).Where("stored_name = ?", stored).Count(&count)
|
||||
if count == 0 {
|
||||
os.Remove(filepath.Join(attachmentsDir(storagePath), stored)) // ignore error
|
||||
os.Remove(filepath.Join(attachmentsDir(storagePath), stored)) // 忽略错误
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- List ----------------
|
||||
// ---------------- 列表 ----------------
|
||||
|
||||
// ListAttachments returns the attachments for an article as JSON (used by the
|
||||
// edit page to repopulate the list on load).
|
||||
// ListAttachments 以 JSON 返回文章的附件(供编辑页加载时重新填充列表)。
|
||||
// 只有文章作者(或管理员)可以列出。
|
||||
func ListAttachments(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
articleID := parseUintParam(c, "id")
|
||||
if articleID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid article id"})
|
||||
return
|
||||
}
|
||||
if !canManageArticle(c, db, articleID) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
var atts []models.Attachment
|
||||
db.Where("article_id = ?", articleID).Order("created_at ASC").Find(&atts)
|
||||
|
||||
@@ -197,11 +255,11 @@ func ListAttachments(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Binding (plan A) ----------------
|
||||
// ---------------- 绑定(方案 A)----------------
|
||||
|
||||
// BindPendingAttachments attaches attachments uploaded during article creation
|
||||
// (owned by session_token, article_id=0) to a newly created article. Called by
|
||||
// ArticleCreate after the article row is saved.
|
||||
// BindPendingAttachments 将文章创建期间上传的附件
|
||||
// (由 session_token 持有、article_id=0)绑定到新创建的文章。
|
||||
// 由 ArticleCreate 在保存文章行之后调用。
|
||||
func BindPendingAttachments(db *gorm.DB, token string, articleID uint) error {
|
||||
if token == "" {
|
||||
return nil
|
||||
@@ -211,22 +269,28 @@ func BindPendingAttachments(db *gorm.DB, token string, articleID uint) error {
|
||||
Updates(map[string]interface{}{"article_id": articleID, "session_token": ""}).Error
|
||||
}
|
||||
|
||||
// ---------------- helpers ----------------
|
||||
// ---------------- 辅助函数 ----------------
|
||||
|
||||
// parseUintForm parses a uint form field, tolerating empty/invalid input.
|
||||
func parseUintForm(c *gin.Context, field string) uint {
|
||||
v := strings.TrimSpace(c.PostForm(field))
|
||||
if v == "" {
|
||||
// parseUintStrict 严格解析十进制 uint:空值、非数字、前缀数字("5abc")
|
||||
// 与超出范围的值一律返回 0(SECURITY_TODO #32——旧的 Sscanf("%d") 会把
|
||||
// "5abc" 宽松解析为 5,掩盖非法输入)。
|
||||
func parseUintStrict(s string) uint {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
var n uint
|
||||
_, _ = fmt.Sscanf(v, "%d", &n)
|
||||
return n
|
||||
n, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil || n > uint64(^uint(0)) {
|
||||
return 0
|
||||
}
|
||||
return uint(n)
|
||||
}
|
||||
|
||||
// parseUintParam parses a uint route param.
|
||||
func parseUintParam(c *gin.Context, name string) uint {
|
||||
var n uint
|
||||
_, _ = fmt.Sscanf(c.Param(name), "%d", &n)
|
||||
return n
|
||||
// parseUintForm 解析 uint 表单字段(严格;空/非法输入返回 0)。
|
||||
func parseUintForm(c *gin.Context, field string) uint {
|
||||
return parseUintStrict(strings.TrimSpace(c.PostForm(field)))
|
||||
}
|
||||
|
||||
// parseUintParam 解析 uint 路由参数(严格;空/非法输入返回 0)。
|
||||
func parseUintParam(c *gin.Context, name string) uint {
|
||||
return parseUintStrict(c.Param(name))
|
||||
}
|
||||
+122
-45
@@ -6,12 +6,13 @@ import (
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// LoginPage renders the login form.
|
||||
// LoginPage 渲染登录表单。
|
||||
func LoginPage() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
@@ -20,7 +21,10 @@ func LoginPage() gin.HandlerFunc {
|
||||
if c.Query("error") == "1" {
|
||||
data["Error"] = tr["login_error"]
|
||||
}
|
||||
// Check if registration is allowed from site settings
|
||||
if c.Query("error") == "locked" {
|
||||
data["Error"] = tr["login_locked"]
|
||||
}
|
||||
// 根据站点设置检查是否允许注册
|
||||
siteSetting, _ := c.Get("site_setting")
|
||||
if s, ok := siteSetting.(*models.SiteSetting); ok && s != nil {
|
||||
data["AllowRegistration"] = s.AllowRegistration
|
||||
@@ -29,61 +33,98 @@ func LoginPage() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Login processes the login form submission.
|
||||
func Login(db *gorm.DB) gin.HandlerFunc {
|
||||
// loginRequest 是 POST /api/auth/login 的 JSON 请求体。
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// Login 处理登录表单提交。它对每个 IP+用户名实施速率限制
|
||||
// (SECURITY_TODO #10),对于不存在的用户名会执行一次虚拟 bcrypt 比较,
|
||||
// 使耗时不会暴露用户名是否存在(SECURITY_TODO #25)。
|
||||
func Login(db *gorm.DB, limiter *LoginRateLimiter) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
username := c.PostForm("username")
|
||||
password := c.PostForm("password")
|
||||
var req loginRequest
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
username := req.Username
|
||||
password := req.Password
|
||||
|
||||
key := GetClientIP(c) + "\x00" + username
|
||||
if !limiter.Allow(key) {
|
||||
APIError(c, http.StatusTooManyRequests, "login_locked")
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/login?error=1")
|
||||
// 常量时间:失败前执行与真实密码校验等量的工作(bcrypt 比较),
|
||||
// 使耗时不会暴露用户名是否存在。
|
||||
limiter.Fail(key)
|
||||
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
|
||||
APIError(c, http.StatusUnauthorized, "login_error")
|
||||
return
|
||||
}
|
||||
|
||||
if !user.CheckPassword(password) {
|
||||
c.Redirect(http.StatusFound, "/login?error=1")
|
||||
limiter.Fail(key)
|
||||
APIError(c, http.StatusUnauthorized, "login_error")
|
||||
return
|
||||
}
|
||||
|
||||
// Refuse login for non-normal accounts (disabled / locked / unactivated).
|
||||
// 拒绝非正常状态账户登录(已禁用 / 已锁定 / 未激活)。
|
||||
if user.Status != models.StatusNormal {
|
||||
c.Redirect(http.StatusFound, "/login?error=1")
|
||||
APIError(c, http.StatusUnauthorized, "login_error")
|
||||
return
|
||||
}
|
||||
|
||||
// Create session.
|
||||
// 成功:重置此键的失败计数。
|
||||
limiter.Reset(key)
|
||||
|
||||
// 权限变更时轮换会话,防止会话固定攻击:
|
||||
// 丢弃全部登录前状态,仅保留无害的 UI 偏好(语言和 CSRF 令牌,
|
||||
// 使其他标签页中已渲染的表单仍然有效)。
|
||||
session := sessions.Default(c)
|
||||
lang, _ := session.Get("lang").(string)
|
||||
csrfTok, _ := session.Get("csrf_token").(string)
|
||||
session.Clear()
|
||||
if lang != "" {
|
||||
session.Set("lang", lang)
|
||||
}
|
||||
if csrfTok != "" {
|
||||
session.Set("csrf_token", csrfTok)
|
||||
}
|
||||
session.Set("user_id", user.ID)
|
||||
session.Set("username", user.Username)
|
||||
if err := session.Save(); err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to save session")
|
||||
APIError(c, http.StatusInternalServerError, "api_error")
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect based on user role: admins to /admin, others to home
|
||||
// 根据用户角色跳转:管理员到 /admin,其他用户到首页
|
||||
redirect := "/"
|
||||
if user.Role == models.RoleAdmin {
|
||||
c.Redirect(http.StatusFound, "/admin")
|
||||
} else {
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
redirect = "/admin"
|
||||
}
|
||||
APIOK(c, redirect, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Logout clears the session and redirects home.
|
||||
// Logout 清除会话并返回跳转首页指令,由前端 fetch 发起跳转。
|
||||
func Logout() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
session.Clear()
|
||||
session.Save()
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
APIOK(c, "/", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterPage renders the registration form (only when registration is enabled).
|
||||
// RegisterPage 渲染注册表单(仅在启用注册时可用)。
|
||||
func RegisterPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Check if registration is allowed
|
||||
// 检查是否允许注册
|
||||
var s models.SiteSetting
|
||||
if err := db.First(&s, 1).Error; err != nil || !s.AllowRegistration {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
@@ -102,51 +143,78 @@ func RegisterPage(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Register processes the registration form submission.
|
||||
func Register(db *gorm.DB) gin.HandlerFunc {
|
||||
// registerRequest 是 POST /api/auth/register 的 JSON 请求体。
|
||||
type registerRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
ConfirmPassword string `json:"confirm_password"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// Register 处理注册表单提交。它对每个 IP 实施速率限制
|
||||
// (SECURITY_TODO #27),防止批量注册垃圾账户。
|
||||
func Register(db *gorm.DB, limiter *WindowRateLimiter) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Check if registration is allowed
|
||||
var s models.SiteSetting
|
||||
if err := db.First(&s, 1).Error; err != nil || !s.AllowRegistration {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
// SECURITY_TODO #27:按 IP 限流注册(10 次/小时),
|
||||
// 键加前缀与登录限流器区分。超限直接 429,不执行任何数据库工作。
|
||||
if !limiter.Allow("register\x00" + GetClientIP(c)) {
|
||||
APIError(c, http.StatusTooManyRequests, "register_locked")
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(c.PostForm("username"))
|
||||
password := c.PostForm("password")
|
||||
confirmPassword := c.PostForm("confirm_password")
|
||||
email := strings.TrimSpace(c.PostForm("email"))
|
||||
displayName := strings.TrimSpace(c.PostForm("display_name"))
|
||||
// 检查是否允许注册
|
||||
var s models.SiteSetting
|
||||
if err := db.First(&s, 1).Error; err != nil || !s.AllowRegistration {
|
||||
APIError(c, http.StatusForbidden, "registration_disabled")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate inputs
|
||||
var req registerRequest
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(req.Username)
|
||||
password := req.Password
|
||||
confirmPassword := req.ConfirmPassword
|
||||
email := strings.TrimSpace(req.Email)
|
||||
displayName := strings.TrimSpace(req.DisplayName)
|
||||
|
||||
// 校验输入
|
||||
if username == "" || password == "" {
|
||||
c.Redirect(http.StatusFound, "/register?error=register_required")
|
||||
APIError(c, http.StatusBadRequest, "register_required")
|
||||
return
|
||||
}
|
||||
|
||||
if len(username) < 3 || len(username) > 32 {
|
||||
c.Redirect(http.StatusFound, "/register?error=register_username_length")
|
||||
APIError(c, http.StatusBadRequest, "register_username_length")
|
||||
return
|
||||
}
|
||||
|
||||
if len(password) < 6 {
|
||||
c.Redirect(http.StatusFound, "/register?error=register_password_length")
|
||||
APIError(c, http.StatusBadRequest, "register_password_length")
|
||||
return
|
||||
}
|
||||
|
||||
if password != confirmPassword {
|
||||
c.Redirect(http.StatusFound, "/register?error=register_password_mismatch")
|
||||
APIError(c, http.StatusBadRequest, "register_password_mismatch")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
// SECURITY (#24):拒绝格式非法的邮箱地址(可选字段)。
|
||||
if !validateEmail(email) {
|
||||
APIError(c, http.StatusBadRequest, "register_email_invalid")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查用户名是否已存在
|
||||
var existingUser models.User
|
||||
if err := db.Where("username = ?", username).First(&existingUser).Error; err == nil {
|
||||
c.Redirect(http.StatusFound, "/register?error=user_username_exists")
|
||||
APIError(c, http.StatusConflict, "user_username_exists")
|
||||
return
|
||||
}
|
||||
|
||||
// Create new user
|
||||
// 创建新用户
|
||||
user := models.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
@@ -160,25 +228,34 @@ func Register(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
if err := user.SetPassword(password); err != nil {
|
||||
c.Redirect(http.StatusFound, "/register?error=register_error")
|
||||
APIError(c, http.StatusInternalServerError, "register_error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/register?error=register_error")
|
||||
APIError(c, http.StatusInternalServerError, "register_error")
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-login after successful registration
|
||||
// 注册成功后自动登录(与登录处理器一致的会话轮换)。
|
||||
session := sessions.Default(c)
|
||||
lang, _ := session.Get("lang").(string)
|
||||
csrfTok, _ := session.Get("csrf_token").(string)
|
||||
session.Clear()
|
||||
if lang != "" {
|
||||
session.Set("lang", lang)
|
||||
}
|
||||
if csrfTok != "" {
|
||||
session.Set("csrf_token", csrfTok)
|
||||
}
|
||||
session.Set("user_id", user.ID)
|
||||
session.Set("username", user.Username)
|
||||
if err := session.Save(); err != nil {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
APIError(c, http.StatusInternalServerError, "api_error")
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to home page
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
// 首页
|
||||
APIOK(c, "/", nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// --- 请求体大小限制(SECURITY_TODO #26)---
|
||||
|
||||
// TestBodyLimitRejectsOversizedJSON 验证未认证 JSON 端点的请求体超限防护:
|
||||
// Content-Length 已知且超限时立即 413(不进入 CSRF 解析),未知长度
|
||||
//(chunked)时在读取处被 MaxBytesReader 截断,同样 413。
|
||||
func TestBodyLimitRejectsOversizedJSON(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
anonCookie, w := e.anonSession()
|
||||
token := e.csrfTokenFrom(t, w)
|
||||
|
||||
// 4 MiB + 1 KiB 的超长 username 字段。
|
||||
huge := strings.Repeat("A", 4<<20+1024)
|
||||
|
||||
// 情形 1:已知 Content-Length(bytes.Reader 可 Seek)→ 进入处理器前
|
||||
// 即被 413 拒绝。
|
||||
w = postJSON(e, http.MethodPost, "/api/auth/login", anonCookie, token,
|
||||
map[string]string{"username": huge, "password": "x"})
|
||||
if w.Code != http.StatusRequestEntityTooLarge || respCode(w) != "request_too_large" {
|
||||
t.Fatalf("known length: status=%d code=%q body=%s, want 413/request_too_large",
|
||||
w.Code, respCode(w), w.Body.String())
|
||||
}
|
||||
|
||||
// 情形 2:无 Content-Length(流式 body)→ MaxBytesReader 在读取中
|
||||
// 截断,bindJSON 识别后返回 413。
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login",
|
||||
io.LimitReader(bytes.NewReader([]byte(`{"username":"`+huge+`"}`)), int64(len(huge))+64))
|
||||
req.ContentLength = -1
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-CSRF-Token", token)
|
||||
req.Header.Set("Cookie", anonCookie)
|
||||
w = httptest.NewRecorder()
|
||||
e.router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusRequestEntityTooLarge || respCode(w) != "request_too_large" {
|
||||
t.Fatalf("unknown length: status=%d code=%q body=%s, want 413/request_too_large",
|
||||
w.Code, respCode(w), w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodyLimitAllowsNormalJSON 验证合法大小的请求不受影响:
|
||||
// 未认证 POST 走到登录校验(401 login_error),而非 413。
|
||||
func TestBodyLimitAllowsNormalJSON(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
anonCookie, w := e.anonSession()
|
||||
token := e.csrfTokenFrom(t, w)
|
||||
|
||||
w = postJSON(e, http.MethodPost, "/api/auth/login", anonCookie, token,
|
||||
map[string]string{"username": "alice", "password": "wrong"})
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("normal body: status=%d, want 401 (passed through, rejected by auth)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodyLimitRejectsOversizedMultipart 验证 multipart 上传路径:
|
||||
// 超过平台策略(默认 1 MiB + 1 MiB 开销)的请求被 CSRF/处理器解析前
|
||||
// 的上限截断,不落盘、不创建附件行。
|
||||
func TestBodyLimitRejectsOversizedMultipart(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
alice := e.login(t, "alice")
|
||||
token := e.csrfTokenFor(t, alice)
|
||||
|
||||
// 构造 ~3 MiB 的 multipart 文件(远超 1 MiB 默认 + 1 MiB 开销)。
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
_ = mw.WriteField("session_token", "tok")
|
||||
fw, _ := mw.CreateFormFile("file", "big.txt")
|
||||
_, _ = fw.Write(bytes.Repeat([]byte("x"), 3<<20))
|
||||
_ = mw.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/my/articles/attachments", &buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("X-CSRF-Token", token)
|
||||
req.Header.Set("Cookie", alice)
|
||||
w := httptest.NewRecorder()
|
||||
e.router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusRequestEntityTooLarge && w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("oversized multipart: status=%d, want 413/400 (rejected before disk write)", w.Code)
|
||||
}
|
||||
|
||||
var count int64
|
||||
e.db.Model(&models.Attachment{}).Where("filename = ?", "big.txt").Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatalf("oversized multipart created %d attachment rows, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodyLimitGETBypass 验证安全方法不受限制(GET 无请求体)。
|
||||
func TestBodyLimitGETBypass(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
w := e.do(http.MethodGet, "/login", "", nil, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /login: status=%d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
+78
-80
@@ -16,163 +16,167 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go_blog/middleware"
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// MaxCommentLength bounds the size of a single comment body, in characters.
|
||||
// MaxCommentLength 限制单条评论正文的长度(字符数)。
|
||||
const MaxCommentLength = 4000
|
||||
|
||||
// guestCookieName is the long-lived cookie used to identify anonymous
|
||||
// commenters so they can see their own pending/private comments.
|
||||
// guestCookieName 是用于标识匿名评论者的长期 Cookie,
|
||||
// 使其能看到自己的待审/私密评论。
|
||||
const guestCookieName = "comment_uid"
|
||||
const guestCookieMaxAge = 365 * 24 * 3600 // one year
|
||||
const guestCookieMaxAge = 365 * 24 * 3600 // 一年
|
||||
|
||||
// htmlTagPattern matches any HTML/XML tag so it can be stripped from comment
|
||||
// markdown before storage. Markdown syntax itself contains no angle brackets
|
||||
// in a form that would collide (the only such construct is autolinks like
|
||||
// <http://…>, which are rare in comments and acceptable to lose).
|
||||
// htmlTagPattern 匹配所有 HTML/XML 标签,以便在存储前从评论 Markdown 中剥除。
|
||||
// Markdown 语法本身不含会冲突的尖括号形式(唯一类似结构是
|
||||
// <http://…> 这样的自动链接,在评论中很少见,可以接受丢失)。
|
||||
var htmlTagPattern = regexp.MustCompile(`(?is)<[^>]+>`)
|
||||
|
||||
// dangerousSchemePattern matches dangerous URL schemes inside markdown link
|
||||
// targets that could execute script when rendered to innerHTML.
|
||||
// dangerousSchemePattern 匹配 Markdown 链接目标中的危险 URL 协议,
|
||||
// 这些协议在渲染为 innerHTML 时可能执行脚本。
|
||||
var dangerousSchemePattern = regexp.MustCompile(`(?i)\b(javascript|vbscript|data:text/html)\s*:`)
|
||||
|
||||
// commentForm holds the parsed values of a submitted comment form so the
|
||||
// template can refill the inputs after a validation failure.
|
||||
// commentForm 是 POST /api/article/:slug/comments 的 JSON 请求体。
|
||||
// 校验失败时返回 {ok:false,code,error},不再回填模板。
|
||||
type commentForm struct {
|
||||
Name string
|
||||
Email string
|
||||
Website string
|
||||
Content string
|
||||
IsPrivate bool
|
||||
ParentID string
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Website string `json:"website"`
|
||||
Content string `json:"content"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
ParentID string `json:"parent_id"`
|
||||
}
|
||||
|
||||
// sanitizeMarkdown strips HTML tags and dangerous URL schemes from a comment
|
||||
// body before storage. Markdown syntax is preserved so the frontend can render
|
||||
// it. This is the first of two XSS defenses; the frontend also runs the output
|
||||
// through marked + DOMPurify.
|
||||
// sanitizeMarkdown 在存储前从评论正文中剥除 HTML 标签与危险 URL 协议。
|
||||
// Markdown 语法被保留,以便前端渲染。这是两道 XSS 防御中的第一道;
|
||||
// 前端还会将输出经过 marked + DOMPurify 处理。
|
||||
func sanitizeMarkdown(s string) string {
|
||||
s = htmlTagPattern.ReplaceAllString(s, "")
|
||||
s = dangerousSchemePattern.ReplaceAllString(s, "#")
|
||||
// Collapse runs of more than two newlines.
|
||||
// 折叠超过两个连续换行符的序列。
|
||||
s = regexp.MustCompile(`\n{3,}`).ReplaceAllString(s, "\n\n")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// newGuestToken generates a random hex token for an anonymous commenter.
|
||||
// newGuestToken 为匿名评论者生成随机十六进制令牌。
|
||||
func newGuestToken() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// Extremely unlikely; fall back to a timestamp-based token.
|
||||
// 极罕见;回退到基于时间戳的令牌。
|
||||
return fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// guestTokenFrom reads the anonymous commenter cookie, creating and setting a
|
||||
// new one when absent. The token is returned for storage on the comment.
|
||||
// guestTokenFrom 读取匿名评论者 Cookie,缺失时创建并设置新值。
|
||||
// 令牌返回给调用方以存储到评论上。
|
||||
func guestTokenFrom(c *gin.Context) string {
|
||||
token, _ := c.Cookie(guestCookieName)
|
||||
if token == "" {
|
||||
token = newGuestToken()
|
||||
}
|
||||
// (Re)set the cookie so returning visitors keep their identity. HttpOnly
|
||||
// prevents JS access; SameSite=Lax is the gin default and is appropriate.
|
||||
c.SetCookie(guestCookieName, token, guestCookieMaxAge, "/", "", false, true)
|
||||
// (重新)设置 Cookie,使回访访客保持身份一致。HttpOnly 阻止 JS 访问;
|
||||
// SameSite=Lax 加上 HTTPS 下的 Secure 与会话 Cookie 加固措施一致。
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(guestCookieName, token, guestCookieMaxAge, "/", "", middleware.IsHTTPSRequest(c), true)
|
||||
return token
|
||||
}
|
||||
|
||||
// emailHash returns the md5 of a lowercased, trimmed email, per the Gravatar
|
||||
// spec.
|
||||
// emailHash 按照 Gravatar 规范返回小写并去除空白后的邮箱的 md5 值。
|
||||
func emailHash(email string) string {
|
||||
h := md5.Sum([]byte(strings.ToLower(strings.TrimSpace(email))))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// PostComment handles submission of a new comment (or reply) on an article.
|
||||
func PostComment(db *gorm.DB) gin.HandlerFunc {
|
||||
// PostComment 处理在文章上提交新评论(或回复)。
|
||||
// 它对每个 IP 实施速率限制(SECURITY_TODO #28),防止灌水机刷屏。
|
||||
func PostComment(db *gorm.DB, limiter *WindowRateLimiter) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
// SECURITY_TODO #28:按 IP 限流评论提交(5 条/分钟),超限 429。
|
||||
if !limiter.Allow("comment\x00" + GetClientIP(c)) {
|
||||
APIError(c, http.StatusTooManyRequests, "comments_locked")
|
||||
return
|
||||
}
|
||||
|
||||
slug := c.Param("slug")
|
||||
|
||||
var article models.Article
|
||||
if err := db.Where("slug = ? AND status = ?", slug, models.ArticlePublished).First(&article).Error; err != nil {
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["article_not_found"]
|
||||
c.HTML(http.StatusNotFound, "article_not_found", data)
|
||||
APIError(c, http.StatusNotFound, "article_not_found")
|
||||
return
|
||||
}
|
||||
|
||||
cfg := models.GetCommentConfig()
|
||||
if cfg == nil || !cfg.Enabled {
|
||||
renderArticleDetail(c, db, &article, commentForm{}, tr["comments_disabled"], "")
|
||||
APIError(c, http.StatusForbidden, "comments_disabled")
|
||||
return
|
||||
}
|
||||
|
||||
isLoggedIn, _ := c.Get("is_logged_in")
|
||||
loggedIn, _ := isLoggedIn.(bool)
|
||||
if !loggedIn && !cfg.AllowGuest {
|
||||
renderArticleDetail(c, db, &article, commentForm{}, tr["comments_guests_disabled"], "")
|
||||
APIError(c, http.StatusForbidden, "comments_guests_disabled")
|
||||
return
|
||||
}
|
||||
|
||||
form := commentForm{
|
||||
Name: strings.TrimSpace(c.PostForm("name")),
|
||||
Email: strings.TrimSpace(c.PostForm("email")),
|
||||
Website: strings.TrimSpace(c.PostForm("website")),
|
||||
Content: strings.TrimSpace(c.PostForm("content")),
|
||||
IsPrivate: c.PostForm("is_private") == "1",
|
||||
ParentID: strings.TrimSpace(c.PostForm("parent_id")),
|
||||
var form commentForm
|
||||
if !bindJSON(c, &form) {
|
||||
return
|
||||
}
|
||||
form.Name = strings.TrimSpace(form.Name)
|
||||
form.Email = strings.TrimSpace(form.Email)
|
||||
form.Website = strings.TrimSpace(form.Website)
|
||||
form.Content = strings.TrimSpace(form.Content)
|
||||
form.ParentID = strings.TrimSpace(form.ParentID)
|
||||
|
||||
// --- Validation ---
|
||||
// --- 校验 ---
|
||||
if form.Name == "" || len(form.Name) > 64 {
|
||||
renderArticleDetail(c, db, &article, form, tr["comments_required_name"], "")
|
||||
APIError(c, http.StatusBadRequest, "comments_required_name")
|
||||
return
|
||||
}
|
||||
if form.Email == "" {
|
||||
renderArticleDetail(c, db, &article, form, tr["comments_required_email"], "")
|
||||
APIError(c, http.StatusBadRequest, "comments_required_email")
|
||||
return
|
||||
}
|
||||
if _, err := mail.ParseAddress(form.Email); err != nil {
|
||||
renderArticleDetail(c, db, &article, form, tr["comments_invalid_email"], "")
|
||||
APIError(c, http.StatusBadRequest, "comments_invalid_email")
|
||||
return
|
||||
}
|
||||
if form.Website != "" {
|
||||
u, err := url.Parse(form.Website)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
renderArticleDetail(c, db, &article, form, tr["comments_invalid_url"], "")
|
||||
APIError(c, http.StatusBadRequest, "comments_invalid_url")
|
||||
return
|
||||
}
|
||||
}
|
||||
if form.Content == "" {
|
||||
renderArticleDetail(c, db, &article, form, tr["comments_required_content"], "")
|
||||
APIError(c, http.StatusBadRequest, "comments_required_content")
|
||||
return
|
||||
}
|
||||
if len([]rune(form.Content)) > MaxCommentLength {
|
||||
renderArticleDetail(c, db, &article, form, fmt.Sprintf(tr["comments_too_long"], MaxCommentLength), "")
|
||||
APIErrorf(c, http.StatusBadRequest, "comments_too_long", MaxCommentLength)
|
||||
return
|
||||
}
|
||||
|
||||
// --- Parent validation ---
|
||||
// --- 父评论校验 ---
|
||||
var parentID *uint
|
||||
if form.ParentID != "" {
|
||||
pid, err := strconv.ParseUint(form.ParentID, 10, 64)
|
||||
if err != nil || pid == 0 {
|
||||
renderArticleDetail(c, db, &article, form, tr["comments_required_content"], "")
|
||||
APIError(c, http.StatusBadRequest, "comments_required_content")
|
||||
return
|
||||
}
|
||||
var parent models.Comment
|
||||
if err := db.Where("id = ? AND article_id = ? AND status = ?", pid, article.ID, models.CommentApproved).First(&parent).Error; err != nil {
|
||||
renderArticleDetail(c, db, &article, form, tr["comments_required_content"], "")
|
||||
APIError(c, http.StatusBadRequest, "comments_required_content")
|
||||
return
|
||||
}
|
||||
id := uint(pid)
|
||||
parentID = &id
|
||||
}
|
||||
|
||||
// --- Build comment ---
|
||||
// --- 构建评论 ---
|
||||
comment := models.Comment{
|
||||
ArticleID: article.ID,
|
||||
ParentID: parentID,
|
||||
@@ -201,7 +205,7 @@ func PostComment(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
if err := db.Create(&comment).Error; err != nil {
|
||||
renderArticleDetail(c, db, &article, form, tr["article_error"], "")
|
||||
APIError(c, http.StatusInternalServerError, "article_error")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -211,11 +215,11 @@ func PostComment(db *gorm.DB) gin.HandlerFunc {
|
||||
} else {
|
||||
setCommentFlash(c, getTr(c)["comments_posted"])
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/article/"+slug+anchor)
|
||||
APIOK(c, "/article/"+slug+anchor, gin.H{"comment_id": comment.ID})
|
||||
}
|
||||
}
|
||||
|
||||
// truncate clips s to at most n runes.
|
||||
// truncate 将 s 裁剪为最多 n 个 rune。
|
||||
func truncate(s string, n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
@@ -227,15 +231,14 @@ func truncate(s string, n int) string {
|
||||
return string(r[:n])
|
||||
}
|
||||
|
||||
// commentViewer describes the identity of the current request for the purposes
|
||||
// of comment visibility.
|
||||
// commentViewer 描述当前请求的身份,用于决定评论可见性。
|
||||
type commentViewer struct {
|
||||
userID *uint
|
||||
isAdmin bool
|
||||
guestToken string
|
||||
}
|
||||
|
||||
// viewerFromContext builds a commentViewer from the request/session.
|
||||
// viewerFromContext 基于请求/会话构建 commentViewer。
|
||||
func viewerFromContext(c *gin.Context) commentViewer {
|
||||
v := commentViewer{}
|
||||
if uid := userIDFromSession(c); uid != 0 {
|
||||
@@ -250,14 +253,14 @@ func viewerFromContext(c *gin.Context) commentViewer {
|
||||
return v
|
||||
}
|
||||
|
||||
// canSee reports whether the viewer is allowed to see one comment.
|
||||
// canSee 报告查看者是否被允许查看某条评论。
|
||||
func (v commentViewer) canSee(c *models.Comment) bool {
|
||||
switch c.Status {
|
||||
case models.CommentApproved:
|
||||
if !c.IsPrivate {
|
||||
return true
|
||||
}
|
||||
// Private: admin or author only.
|
||||
// 私密:仅管理员或作者可见。
|
||||
return v.isAdmin || v.owns(c)
|
||||
case models.CommentPending:
|
||||
return v.isAdmin || v.owns(c)
|
||||
@@ -267,7 +270,7 @@ func (v commentViewer) canSee(c *models.Comment) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// owns reports whether the viewer is the author of the comment.
|
||||
// owns 报告查看者是否为该评论的作者。
|
||||
func (v commentViewer) owns(c *models.Comment) bool {
|
||||
if v.userID != nil && c.UserID != nil && *v.userID == *c.UserID {
|
||||
return true
|
||||
@@ -278,11 +281,9 @@ func (v commentViewer) owns(c *models.Comment) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// CommentNode is a comment plus its rendered children, used by the template's
|
||||
// recursive comment_node block. Tr/UseGravatar/AvatarColor are propagated to
|
||||
// every node so the recursive template can render badges and avatars without
|
||||
// reaching back to the page-level data (inside a {{template}} invocation, $
|
||||
// binds to the node, not the page data).
|
||||
// CommentNode 是一条评论及其已渲染的子评论,供模板的递归 comment_node 块使用。
|
||||
// Tr/UseGravatar/AvatarColor 会传播到每个节点,使递归模板能够渲染徽章和头像
|
||||
// 而无需回溯页面级数据(在 {{template}} 调用内部,$ 绑定到节点而非页面数据)。
|
||||
type CommentNode struct {
|
||||
Comment models.Comment
|
||||
Children []CommentNode
|
||||
@@ -293,14 +294,13 @@ type CommentNode struct {
|
||||
AvatarColor string
|
||||
}
|
||||
|
||||
// avatarPalette is the set of background colors used for text-initial avatars
|
||||
// when Gravatar is disabled.
|
||||
// avatarPalette 是禁用 Gravatar 时用于文本首字母头像的背景色集合。
|
||||
var avatarPalette = []string{
|
||||
"#3b82f6", "#ef4444", "#10b981", "#f59e0b",
|
||||
"#8b5cf6", "#ec4899", "#14b8a6", "#6366f1",
|
||||
}
|
||||
|
||||
// avatarColorFor returns a deterministic palette color for a comment ID.
|
||||
// avatarColorFor 为评论 ID 返回确定性的调色板颜色。
|
||||
func avatarColorFor(id uint) string {
|
||||
if len(avatarPalette) == 0 {
|
||||
return "#3b82f6"
|
||||
@@ -308,9 +308,8 @@ func avatarColorFor(id uint) string {
|
||||
return avatarPalette[int(id)%len(avatarPalette)]
|
||||
}
|
||||
|
||||
// buildCommentTree filters comments by visibility and assembles them into a
|
||||
// nested tree ordered by creation time. tr and useGravatar are propagated to
|
||||
// every node for template rendering.
|
||||
// buildCommentTree 按可见性过滤评论,并按创建时间组装为嵌套树。
|
||||
// tr 与 useGravatar 会传播到每个节点以供模板渲染。
|
||||
func buildCommentTree(comments []models.Comment, viewer commentViewer, tr map[string]string, useGravatar bool) []CommentNode {
|
||||
visible := make([]models.Comment, 0, len(comments))
|
||||
for i := range comments {
|
||||
@@ -349,8 +348,7 @@ func buildCommentNode(c models.Comment, byParent map[uint][]models.Comment, dept
|
||||
return node
|
||||
}
|
||||
|
||||
// relativeTime returns a coarse human-readable age for a comment timestamp,
|
||||
// falling back to an absolute date for anything older than a day.
|
||||
// relativeTime 为评论时间戳返回粗略的可读年龄,超过一天后回退到绝对日期。
|
||||
func relativeTime(t time.Time) string {
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
|
||||
+31
-16
@@ -1,14 +1,33 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DefaultData builds a base gin.H map populated with values set by the
|
||||
// SetUserContext middleware (translations, language, auth state). Handlers
|
||||
// add page-specific fields on top and pass it to c.HTML().
|
||||
// minPasswordLength 是接受的最小密码长度,由注册、个人资料密码修改和
|
||||
// 管理员用户管理共用(SECURITY_TODO #23)。与注册策略保持一致。
|
||||
const minPasswordLength = 6
|
||||
|
||||
// validatePassword 报告明文密码是否符合平台策略(与注册相同的最小长度)。
|
||||
func validatePassword(pw string) bool {
|
||||
return len(pw) >= minPasswordLength
|
||||
}
|
||||
|
||||
// validateEmail 报告邮箱地址是否格式正确。空值始终合法(该字段在多数表单中为可选项)。
|
||||
func validateEmail(email string) bool {
|
||||
email = strings.TrimSpace(email)
|
||||
if email == "" {
|
||||
return true
|
||||
}
|
||||
_, err := mail.ParseAddress(email)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// DefaultData 构建基础 gin.H 映射,填充由 SetUserContext 中间件设置的值
|
||||
// (翻译、语言、认证状态)。处理器在其上添加页面特定字段并传给 c.HTML()。
|
||||
func DefaultData(c *gin.Context) gin.H {
|
||||
tr, _ := c.Get("tr")
|
||||
isLoggedIn, _ := c.Get("is_logged_in")
|
||||
@@ -29,6 +48,8 @@ func DefaultData(c *gin.Context) gin.H {
|
||||
siteHomeSubtitle, _ := c.Get("site_home_subtitle")
|
||||
siteFooterText, _ := c.Get("site_footer_text")
|
||||
navLinks, _ := c.Get("nav_links")
|
||||
csrfToken, _ := c.Get("csrf_token")
|
||||
buildInfo, _ := c.Get("build_info")
|
||||
|
||||
return gin.H{
|
||||
"Tr": tr,
|
||||
@@ -50,11 +71,13 @@ func DefaultData(c *gin.Context) gin.H {
|
||||
"SiteHomeSubtitle": siteHomeSubtitle,
|
||||
"SiteFooterText": siteFooterText,
|
||||
"NavLinks": navLinks,
|
||||
"CSRFToken": csrfToken,
|
||||
"BuildInfo": buildInfo,
|
||||
}
|
||||
}
|
||||
|
||||
// getTr is a convenience helper that returns the translation map from the
|
||||
// Gin context, falling back to an empty map if not set.
|
||||
// getTr 是一个便捷辅助函数,从 Gin 上下文返回翻译映射,
|
||||
// 未设置时回退到空映射。
|
||||
func getTr(c *gin.Context) map[string]string {
|
||||
tr, ok := c.Get("tr")
|
||||
if !ok {
|
||||
@@ -67,17 +90,9 @@ func getTr(c *gin.Context) map[string]string {
|
||||
return m
|
||||
}
|
||||
|
||||
// GetClientIP extracts the real client IP address, accounting for CDN/reverse proxy setups.
|
||||
// It checks X-Forwarded-For and X-Real-IP headers before falling back to c.ClientIP().
|
||||
// GetClientIP 返回真实客户端 IP。它依赖 gin 的代理感知 ClientIP(),
|
||||
// 遵循 trusted_proxies 配置:只有该列表中的 IP 才能影响
|
||||
// X-Forwarded-For,因此直接客户端无法伪造该值。
|
||||
func GetClientIP(c *gin.Context) string {
|
||||
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||
if i := strings.IndexByte(xff, ','); i != -1 {
|
||||
return strings.TrimSpace(xff[:i])
|
||||
}
|
||||
return strings.TrimSpace(xff)
|
||||
}
|
||||
if xri := c.GetHeader("X-Real-IP"); xri != "" {
|
||||
return strings.TrimSpace(xri)
|
||||
}
|
||||
return c.ClientIP()
|
||||
}
|
||||
+61
-77
@@ -13,30 +13,30 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// publishedArticleOrder is the ordering used to list published articles:
|
||||
// pinned first, then by most recent publish/creation time.
|
||||
// publishedArticleOrder 是列出已发布文章时使用的排序:
|
||||
// 置顶优先,其次按发布/创建时间从新到旧。
|
||||
const publishedArticleOrder = "articles.is_top DESC, articles.published_at DESC, articles.created_at DESC"
|
||||
|
||||
// HomePage renders the public home page with the latest published articles.
|
||||
// HomePage 渲染公开首页,展示最新发布的文章。
|
||||
func HomePage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["page_home"]
|
||||
|
||||
// Get tag filter if present
|
||||
// 获取标签筛选(如存在)
|
||||
tagSlug := c.Query("tag")
|
||||
|
||||
// Build query
|
||||
// 构建查询
|
||||
query := db.Where("status = ?", models.ArticlePublished)
|
||||
|
||||
if tagSlug != "" {
|
||||
// Join with article_tags to filter by tag
|
||||
// 联表 article_tags 以按标签筛选
|
||||
query = query.Joins("JOIN article_tags ON article_tags.article_id = articles.id").
|
||||
Joins("JOIN tags ON tags.id = article_tags.tag_id").
|
||||
Where("tags.slug = ?", tagSlug)
|
||||
|
||||
// Get tag info for display
|
||||
// 获取标签信息用于展示
|
||||
var tag models.Tag
|
||||
if err := db.Where("slug = ?", tagSlug).First(&tag).Error; err == nil {
|
||||
data["FilterTag"] = tag
|
||||
@@ -49,11 +49,11 @@ func HomePage(db *gorm.DB) gin.HandlerFunc {
|
||||
Limit(10).
|
||||
Find(&articles)
|
||||
|
||||
// Load all tags for sidebar
|
||||
// 加载所有标签用于侧边栏
|
||||
var tags []models.Tag
|
||||
db.Where("count > 0").Order("count DESC, name_zh ASC").Find(&tags)
|
||||
|
||||
// Get comment counts for all articles
|
||||
// 获取所有文章的评论数量
|
||||
articleIDs := make([]uint, len(articles))
|
||||
for i, article := range articles {
|
||||
articleIDs[i] = article.ID
|
||||
@@ -73,13 +73,13 @@ func HomePage(db *gorm.DB) gin.HandlerFunc {
|
||||
Scan(&commentCounts)
|
||||
}
|
||||
|
||||
// Create a map for quick lookup
|
||||
// 创建用于快速查找的映射
|
||||
commentCountMap := make(map[uint]int64)
|
||||
for _, cc := range commentCounts {
|
||||
commentCountMap[cc.ArticleID] = cc.Count
|
||||
}
|
||||
|
||||
// Add data to template
|
||||
// 向模板添加数据
|
||||
data["Articles"] = articles
|
||||
data["Tags"] = tags
|
||||
data["CommentCounts"] = commentCountMap
|
||||
@@ -88,7 +88,7 @@ func HomePage(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// HomeArticlesAPI returns articles in JSON format for infinite scroll.
|
||||
// HomeArticlesAPI 以 JSON 格式返回文章,用于无限滚动加载。
|
||||
func HomeArticlesAPI(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
page := 1
|
||||
@@ -102,15 +102,15 @@ func HomeArticlesAPI(db *gorm.DB) gin.HandlerFunc {
|
||||
pageSize := 10
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
// Get tag filter if present
|
||||
// 获取标签筛选(如存在)
|
||||
tagSlug := c.Query("tag")
|
||||
|
||||
// Build query
|
||||
// 构建查询
|
||||
query := db.Where("status = ?", models.ArticlePublished)
|
||||
countQuery := db.Model(&models.Article{}).Where("status = ?", models.ArticlePublished)
|
||||
|
||||
if tagSlug != "" {
|
||||
// Join with article_tags to filter by tag
|
||||
// 联表 article_tags 以按标签筛选
|
||||
query = query.Joins("JOIN article_tags ON article_tags.article_id = articles.id").
|
||||
Joins("JOIN tags ON tags.id = article_tags.tag_id").
|
||||
Where("tags.slug = ?", tagSlug)
|
||||
@@ -130,7 +130,7 @@ func HomeArticlesAPI(db *gorm.DB) gin.HandlerFunc {
|
||||
var total int64
|
||||
countQuery.Count(&total)
|
||||
|
||||
// Get comment counts for these articles
|
||||
// 获取这些文章的评论数量
|
||||
articleIDs := make([]uint, len(articles))
|
||||
for i, article := range articles {
|
||||
articleIDs[i] = article.ID
|
||||
@@ -150,13 +150,13 @@ func HomeArticlesAPI(db *gorm.DB) gin.HandlerFunc {
|
||||
Scan(&commentCounts)
|
||||
}
|
||||
|
||||
// Create a map for quick lookup
|
||||
// 创建用于快速查找的映射
|
||||
commentCountMap := make(map[uint]int64)
|
||||
for _, cc := range commentCounts {
|
||||
commentCountMap[cc.ArticleID] = cc.Count
|
||||
}
|
||||
|
||||
// Build response with comment counts
|
||||
// 构建带评论数量的响应
|
||||
type ArticleResponse struct {
|
||||
models.Article
|
||||
CommentCount int64 `json:"comment_count"`
|
||||
@@ -177,7 +177,7 @@ func HomeArticlesAPI(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// ArticleDetail renders a single published article by its slug.
|
||||
// ArticleDetail 按 slug 渲染单篇已发布文章。
|
||||
func ArticleDetail(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
@@ -194,23 +194,32 @@ func ArticleDetail(db *gorm.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Increment view count; ignore errors so a view never breaks the page.
|
||||
// 增加浏览量;忽略错误,确保一次浏览不会破坏页面。
|
||||
db.Model(&models.Article{}).Where("id = ?", article.ID).
|
||||
UpdateColumn("view_count", gorm.Expr("view_count + 1"))
|
||||
|
||||
// Record unique article view asynchronously (doesn't block page response).
|
||||
go recordArticleView(db, article.ID, c)
|
||||
// 异步记录唯一文章浏览(不阻塞页面响应)。所有从请求派生的值
|
||||
// 都在当前 goroutine 中同步提取:gin 上下文会复用,
|
||||
// 处理器返回后严禁在其他 goroutine 中访问。
|
||||
uid := userIDFromSession(c)
|
||||
var userID *uint
|
||||
if uid != 0 {
|
||||
userID = &uid
|
||||
}
|
||||
ip := GetClientIP(c)
|
||||
ua := c.Request.UserAgent()
|
||||
go recordArticleView(db, article.ID, userID, ip, ua)
|
||||
|
||||
// One-time flash notice (set by PostComment on success/pending). Reading
|
||||
// consumes the flash, so refreshing the page no longer re-shows it.
|
||||
// 一次性 flash 通知(由 PostComment 在成功/待审时设置)。读取即消耗
|
||||
// flash,因此刷新页面不会再显示。
|
||||
notice := readCommentFlash(c)
|
||||
renderArticleDetail(c, db, &article, commentForm{}, "", notice)
|
||||
}
|
||||
}
|
||||
|
||||
// renderArticleDetail renders the article page, including the comment section.
|
||||
// formErr refills the form with an error banner; notice is a one-time
|
||||
// success/pending banner (already consumed from the session by the caller).
|
||||
// renderArticleDetail 渲染文章页面,包括评论区域。
|
||||
// formErr 带错误横幅重新填充表单;notice 是一次性成功/待审横幅
|
||||
// (调用方已从会话中读取消耗)。
|
||||
func renderArticleDetail(c *gin.Context, db *gorm.DB, article *models.Article, form commentForm, formErr, notice string) {
|
||||
tr := getTr(c)
|
||||
|
||||
@@ -242,8 +251,8 @@ func renderArticleDetail(c *gin.Context, db *gorm.DB, article *models.Article, f
|
||||
c.HTML(http.StatusOK, "article", data)
|
||||
}
|
||||
|
||||
// formatPublishTime returns the publication time as a readable string, falling
|
||||
// back to the creation time when the publish timestamp is unset.
|
||||
// formatPublishTime 返回可读的发布时间字符串,当发布时间为空时
|
||||
// 回退到创建时间。
|
||||
func formatPublishTime(publishedAt *time.Time) string {
|
||||
if publishedAt != nil {
|
||||
return publishedAt.Format("2006-01-02 15:04")
|
||||
@@ -251,24 +260,23 @@ func formatPublishTime(publishedAt *time.Time) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// formatUpdateTime returns the last update time as a readable string.
|
||||
// formatUpdateTime 返回可读的最后更新时间字符串。
|
||||
func formatUpdateTime(updatedAt time.Time) string {
|
||||
return updatedAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
// commentFlashKey is the session key for the one-time comment notice.
|
||||
// commentFlashKey 是一次性评论通知的会话键。
|
||||
const commentFlashKey = "comment_flash"
|
||||
|
||||
// setCommentFlash stores a one-time comment notice in the session so the
|
||||
// following GET /article/:slug (after the POST/redirect) can show it once and
|
||||
// never again on refresh.
|
||||
// setCommentFlash 在会话中保存一次性评论通知,使 POST/重定向后的
|
||||
// GET /article/:slug 能显示一次,刷新后不再显示。
|
||||
func setCommentFlash(c *gin.Context, value string) {
|
||||
session := sessions.Default(c)
|
||||
session.Set(commentFlashKey, value)
|
||||
session.Save()
|
||||
}
|
||||
|
||||
// readCommentFlash returns and clears the one-time comment notice, if any.
|
||||
// readCommentFlash 返回并清除一次性评论通知(如有)。
|
||||
func readCommentFlash(c *gin.Context) string {
|
||||
session := sessions.Default(c)
|
||||
v, ok := session.Get(commentFlashKey).(string)
|
||||
@@ -280,37 +288,13 @@ func readCommentFlash(c *gin.Context) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// recordArticleView records a unique article view in the database.
|
||||
// This function is designed to be called asynchronously (via goroutine) to avoid
|
||||
// blocking the page response. It checks for existing records to ensure each
|
||||
// user/IP combination only records one view per article.
|
||||
func recordArticleView(db *gorm.DB, articleID uint, c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
|
||||
// Extract user ID from session if logged in
|
||||
var userID *uint
|
||||
if uid := session.Get("user_id"); uid != nil {
|
||||
switch v := uid.(type) {
|
||||
case uint:
|
||||
userID = &v
|
||||
case int:
|
||||
u := uint(v)
|
||||
userID = &u
|
||||
case int64:
|
||||
u := uint(v)
|
||||
userID = &u
|
||||
case float64:
|
||||
u := uint(v)
|
||||
userID = &u
|
||||
}
|
||||
}
|
||||
|
||||
// Get client IP and User-Agent
|
||||
ip := GetClientIP(c)
|
||||
userAgent := c.Request.UserAgent()
|
||||
isBot := models.IsBot(userAgent)
|
||||
|
||||
// Check if this view already exists (deduplication)
|
||||
// recordArticleView 在数据库中记录一条唯一的文章浏览。
|
||||
// 该函数设计为异步调用(通过 goroutine),避免阻塞页面响应。
|
||||
// 它会检查已有记录,确保每个用户/IP 组合对每篇文章只记录一次浏览。
|
||||
// 所有从请求派生的值(userID、ip、userAgent)必须在 goroutine 启动前
|
||||
// 由调用方提取——此函数绝不触碰 gin 上下文。
|
||||
func recordArticleView(db *gorm.DB, articleID uint, userID *uint, ip, userAgent string) {
|
||||
// 检查该浏览是否已存在(去重)
|
||||
var count int64
|
||||
query := db.Model(&models.ArticleView{}).
|
||||
Where("article_id = ? AND ip = ?", articleID, ip)
|
||||
@@ -322,30 +306,30 @@ func recordArticleView(db *gorm.DB, articleID uint, c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
// Silently fail - don't break the user experience
|
||||
// 静默失败——不影响用户体验
|
||||
return
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
// Already recorded
|
||||
// 已记录
|
||||
return
|
||||
}
|
||||
|
||||
// Create new view record using INSERT IGNORE pattern
|
||||
// 使用类似 INSERT IGNORE 的模式创建新浏览记录
|
||||
view := models.ArticleView{
|
||||
ArticleID: articleID,
|
||||
UserID: userID,
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
IsBot: isBot,
|
||||
IsBot: models.IsBot(userAgent),
|
||||
}
|
||||
|
||||
// Create the view record (BeforeCreate hook in model handles deduplication)
|
||||
// 创建浏览记录(模型中的 BeforeCreate 钩子负责去重)
|
||||
db.Create(&view)
|
||||
// Ignore errors - this is a best-effort tracking system
|
||||
// 忽略错误——这是一个尽力而为的统计系统
|
||||
}
|
||||
|
||||
// SearchPage handles article search by keyword.
|
||||
// SearchPage 处理按关键字搜索文章。
|
||||
func SearchPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
@@ -363,21 +347,21 @@ func SearchPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Search in title, summary, and content
|
||||
// 在标题、摘要和正文中搜索
|
||||
searchPattern := "%" + keyword + "%"
|
||||
var articles []models.Article
|
||||
db.Where("status = ?", models.ArticlePublished).
|
||||
Where("title LIKE ? OR summary LIKE ? OR content LIKE ?", searchPattern, searchPattern, searchPattern).
|
||||
Preload("Tags").
|
||||
Order(publishedArticleOrder).
|
||||
Limit(50). // Limit search results
|
||||
Limit(50). // 限制搜索结果数量
|
||||
Find(&articles)
|
||||
|
||||
// Load all tags for sidebar
|
||||
// 加载所有标签用于侧边栏
|
||||
var tags []models.Tag
|
||||
db.Where("count > 0").Order("count DESC, name_zh ASC").Find(&tags)
|
||||
|
||||
// Get comment counts
|
||||
// 获取评论数量
|
||||
articleIDs := make([]uint, len(articles))
|
||||
for i, article := range articles {
|
||||
articleIDs[i] = article.ID
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// TestConcurrentLastAdminDowngrade 覆盖 SECURITY_TODO #30:两个并发的
|
||||
// "降级倒数第二位管理员"请求不再能同时通过检查——最终恰好一位管理员
|
||||
// 被降级、另一位被拒绝,且站点至少保留一位管理员。旧实现(计数与写入
|
||||
// 非原子)下两个请求都会成功,管理员清零(变异测试可验证)。
|
||||
func TestConcurrentLastAdminDowngrade(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
|
||||
// 增加第二位管理员(admin2);alice/bob 保持 author。
|
||||
admin2 := mustUser(t, e.db, "admin2", models.RoleAdmin)
|
||||
var admin1 models.User
|
||||
if err := e.db.Where("username = ?", "admin").First(&admin1).Error; err != nil {
|
||||
t.Fatalf("load admin1: %v", err)
|
||||
}
|
||||
|
||||
// 两位管理员分别用自己的会话并发发起"降级自己"的请求。
|
||||
session1 := e.login(t, "admin")
|
||||
token1 := e.csrfTokenFor(t, session1)
|
||||
session2 := e.login(t, "admin2")
|
||||
token2 := e.csrfTokenFor(t, session2)
|
||||
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
codes := make(chan int, 2)
|
||||
requests := []struct {
|
||||
id uint
|
||||
cookie string
|
||||
token string
|
||||
}{
|
||||
{admin1.ID, session1, token1},
|
||||
{admin2.ID, session2, token2},
|
||||
}
|
||||
for _, req := range requests {
|
||||
wg.Add(1)
|
||||
go func(id uint, cookie, token string) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
w := postJSON(e, http.MethodPut, fmt.Sprintf("/api/admin/users/%d", id), cookie, token,
|
||||
gin.H{"role": models.RoleAuthor, "status": models.StatusNormal})
|
||||
codes <- w.Code
|
||||
}(req.id, req.cookie, req.token)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(codes)
|
||||
|
||||
var success, rejected int
|
||||
for code := range codes {
|
||||
switch code {
|
||||
case http.StatusOK:
|
||||
success++
|
||||
case http.StatusForbidden:
|
||||
rejected++
|
||||
default:
|
||||
t.Fatalf("unexpected status %d (body-level check skipped; want 200 or 403)", code)
|
||||
}
|
||||
}
|
||||
if success != 1 || rejected != 1 {
|
||||
t.Fatalf("concurrent demotions: success=%d rejected=%d, want exactly 1/1", success, rejected)
|
||||
}
|
||||
|
||||
// 最终至少保留一位管理员。
|
||||
var count int64
|
||||
if err := e.db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count admins: %v", err)
|
||||
}
|
||||
if count < 1 {
|
||||
t.Fatal("no admin remains after concurrent demotions")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// 登录速率限制(SECURITY_TODO #10):按 IP+用户名计数失败次数,
|
||||
// 并设置锁定期窗口,以削弱凭据猜测攻击。该限流器为进程内存实现;
|
||||
// 应用是单实例部署(反向代理后的 unix socket),因此无需共享存储。
|
||||
const (
|
||||
maxLoginFailures = 5
|
||||
loginLockDuration = 15 * time.Minute
|
||||
maxTrackedKeys = 4096
|
||||
// dummyHashCost 与生产环境的 bcrypt 成本一致(models.bcryptCost)。
|
||||
dummyHashCost = 12
|
||||
)
|
||||
|
||||
// LoginRateLimiter 按键("IP|username")跟踪连续的登录失败次数。
|
||||
type LoginRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*loginRateEntry
|
||||
}
|
||||
|
||||
type loginRateEntry struct {
|
||||
failures int
|
||||
lockedUntil time.Time
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// NewLoginLimiter 为登录端点创建空的速率限制器。
|
||||
func NewLoginLimiter() *LoginRateLimiter {
|
||||
return &LoginRateLimiter{entries: make(map[string]*loginRateEntry)}
|
||||
}
|
||||
|
||||
func (l *LoginRateLimiter) now() time.Time { return time.Now() }
|
||||
|
||||
// Allow 报告该键是否允许再次尝试登录。锁定期窗口已过期的键会在此释放;
|
||||
// 仅计数失败次数的键保留其计数。
|
||||
func (l *LoginRateLimiter) Allow(key string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
e := l.entries[key]
|
||||
if e == nil || e.lockedUntil.IsZero() {
|
||||
return true
|
||||
}
|
||||
if now.Before(e.lockedUntil) {
|
||||
return false
|
||||
}
|
||||
// 锁定期窗口已过期:释放该键并重新开始。
|
||||
delete(l.entries, key)
|
||||
return true
|
||||
}
|
||||
|
||||
// Fail 记录该键的一次失败尝试,并返回锁定生效前剩余的可尝试次数
|
||||
// (0 = 刚刚被锁定)。
|
||||
func (l *LoginRateLimiter) Fail(key string) (remaining int) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
e := l.entries[key]
|
||||
if e == nil {
|
||||
e = &loginRateEntry{}
|
||||
l.entries[key] = e
|
||||
} else if !e.lockedUntil.IsZero() && now.After(e.lockedUntil) {
|
||||
// 锁定窗口过期;开始新的计数周期。
|
||||
e.failures = 0
|
||||
e.lockedUntil = time.Time{}
|
||||
}
|
||||
e.failures++
|
||||
e.lastSeen = now
|
||||
if e.failures >= maxLoginFailures {
|
||||
e.lockedUntil = now.Add(loginLockDuration)
|
||||
l.sweep(now)
|
||||
return 0
|
||||
}
|
||||
l.sweep(now)
|
||||
return maxLoginFailures - e.failures
|
||||
}
|
||||
|
||||
// Reset 在登录成功后清除失败计数。
|
||||
func (l *LoginRateLimiter) Reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.entries, key)
|
||||
}
|
||||
|
||||
// sweep 限制映射大小,防止攻击者通过制造大量键使限流器无限增长。
|
||||
// 过期的条目(若无过期条目,则移除最少访问的条目)会被逐出。
|
||||
func (l *LoginRateLimiter) sweep(now time.Time) {
|
||||
if len(l.entries) <= maxTrackedKeys {
|
||||
return
|
||||
}
|
||||
// 第 1 轮:移除锁定期已过或计数已空闲超过一个完整登录窗口的键。
|
||||
for k, e := range l.entries {
|
||||
if now.Sub(e.lastSeen) > loginLockDuration {
|
||||
delete(l.entries, k)
|
||||
}
|
||||
}
|
||||
// 第 2 轮:若仍然过大,按 lastSeen 逐出最旧的条目。
|
||||
if len(l.entries) <= maxTrackedKeys {
|
||||
return
|
||||
}
|
||||
cut := len(l.entries) - maxTrackedKeys + maxTrackedKeys/4
|
||||
var byOldest []struct {
|
||||
key string
|
||||
t time.Time
|
||||
}
|
||||
for k, e := range l.entries {
|
||||
byOldest = append(byOldest, struct {
|
||||
key string
|
||||
t time.Time
|
||||
}{k, e.lastSeen})
|
||||
}
|
||||
for i := 1; i < len(byOldest); i++ {
|
||||
for j := i; j > 0 && byOldest[j].t.Before(byOldest[j-1].t); j-- {
|
||||
byOldest[j], byOldest[j-1] = byOldest[j-1], byOldest[j]
|
||||
}
|
||||
}
|
||||
for _, o := range byOldest[:cut] {
|
||||
delete(l.entries, o.key)
|
||||
}
|
||||
}
|
||||
|
||||
// dummyHash 是一个预计算的 bcrypt 哈希,在用户不存在时与之比对,
|
||||
// 使登录耗时不会暴露用户名是否有效(SECURITY_TODO #25)。
|
||||
// 成本与生产环境一致(12,SECURITY_TODO #17),在包初始化时生成一次。
|
||||
var dummyHash, _ = bcrypt.GenerateFromPassword(
|
||||
[]byte("dummy-password-for-constant-time-login"), dummyHashCost)
|
||||
@@ -0,0 +1,92 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// TestUserStatusEnumRejected 覆盖 SECURITY_TODO #32 第一项:
|
||||
// UserCreate/UserUpdate 的 status 仅接受枚举 {0,1,2,3},非法值返回 400
|
||||
// 且数据不变;合法枚举值正常生效。
|
||||
func TestUserStatusEnumRejected(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
admin := e.login(t, "admin")
|
||||
token := e.csrfTokenFor(t, admin)
|
||||
var alice models.User
|
||||
if err := e.db.Where("username = ?", "alice").First(&alice).Error; err != nil {
|
||||
t.Fatalf("load alice: %v", err)
|
||||
}
|
||||
|
||||
for _, bad := range []int{-1, 99} {
|
||||
w := postJSON(e, http.MethodPut, fmt.Sprintf("/api/admin/users/%d", alice.ID), admin, token,
|
||||
gin.H{"username": "alice", "email": "alice@example.com",
|
||||
"role": models.RoleAuthor, "status": bad})
|
||||
if w.Code != http.StatusBadRequest || respCode(w) != "user_status_invalid" {
|
||||
t.Fatalf("status %d: code = %d/%q, want 400/user_status_invalid", bad, w.Code, respCode(w))
|
||||
}
|
||||
}
|
||||
var reloaded models.User
|
||||
if err := e.db.First(&reloaded, alice.ID).Error; err != nil {
|
||||
t.Fatalf("reload alice: %v", err)
|
||||
}
|
||||
if reloaded.Status != models.StatusNormal {
|
||||
t.Fatalf("invalid status persisted: status = %d", reloaded.Status)
|
||||
}
|
||||
|
||||
// 合法枚举值(锁定 → 禁用 → 恢复正常)逐个生效。
|
||||
for _, want := range []int{models.StatusLocked, models.StatusDisabled, models.StatusNormal} {
|
||||
w := postJSON(e, http.MethodPut, fmt.Sprintf("/api/admin/users/%d", alice.ID), admin, token,
|
||||
gin.H{"username": "alice", "email": "alice@example.com",
|
||||
"role": models.RoleAuthor, "status": want})
|
||||
if w.Code != http.StatusOK || !respOK(w) {
|
||||
t.Fatalf("status %d: code = %d, body %s", want, w.Code, w.Body.String())
|
||||
}
|
||||
if err := e.db.First(&reloaded, alice.ID).Error; err != nil {
|
||||
t.Fatalf("reload alice: %v", err)
|
||||
}
|
||||
if reloaded.Status != want {
|
||||
t.Fatalf("status = %d, want %d", reloaded.Status, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseUintStrict 覆盖 SECURITY_TODO #32 第二项:
|
||||
// parseUintParam/parseUintForm 必须严格拒绝 "5abc" 一类的前缀数字输入
|
||||
// (旧的 fmt.Sscanf("%d") 会宽松解析为 5)。
|
||||
func TestParseUintStrict(t *testing.T) {
|
||||
// 路由参数。
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Params = gin.Params{{Key: "id", Value: "5abc"}}
|
||||
if got := parseUintParam(c, "id"); got != 0 {
|
||||
t.Fatalf("parseUintParam(5abc) = %d, want 0", got)
|
||||
}
|
||||
c.Params = gin.Params{{Key: "id", Value: "42"}}
|
||||
if got := parseUintParam(c, "id"); got != 42 {
|
||||
t.Fatalf("parseUintParam(42) = %d, want 42", got)
|
||||
}
|
||||
c.Params = gin.Params{{Key: "id", Value: "18446744073709551616"}} // > uint64
|
||||
if got := parseUintParam(c, "id"); got != 0 {
|
||||
t.Fatalf("parseUintParam(overflow) = %d, want 0", got)
|
||||
}
|
||||
|
||||
// 表单字段(每个用例独立 context,避免 gin 的 form 缓存干扰)。
|
||||
formCtx := func(body string) *gin.Context {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return c
|
||||
}
|
||||
if got := parseUintForm(formCtx("article_id=5abc"), "article_id"); got != 0 {
|
||||
t.Fatalf("parseUintForm(article_id=5abc) = %d, want 0", got)
|
||||
}
|
||||
if got := parseUintForm(formCtx("article_id=7"), "article_id"); got != 7 {
|
||||
t.Fatalf("parseUintForm(article_id=7) = %d, want 7", got)
|
||||
}
|
||||
}
|
||||
+43
-31
@@ -12,7 +12,7 @@ import (
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// MyArticlesPage renders the logged-in user's article management page.
|
||||
// MyArticlesPage 渲染已登录用户的文章管理页面。
|
||||
func MyArticlesPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
@@ -29,7 +29,7 @@ func MyArticlesPage(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// MyArticleCreatePage renders the article creation form for regular users.
|
||||
// MyArticleCreatePage 为普通用户渲染文章创建表单。
|
||||
func MyArticleCreatePage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
@@ -46,12 +46,13 @@ func MyArticleCreatePage(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// MyArticleCreate handles the POST request to create a new article for regular users.
|
||||
// MyArticleCreate 处理普通用户创建新文章的 POST 请求。
|
||||
// 复用创建逻辑,普通作者不可置顶(SECURITY_TODO #31)。
|
||||
func MyArticleCreate(db *gorm.DB) gin.HandlerFunc {
|
||||
return ArticleCreate(db) // Reuse the same logic
|
||||
return articleCreate(db, "/my/articles", false)
|
||||
}
|
||||
|
||||
// MyArticleEditPage renders the article edit form for the logged-in user's own articles.
|
||||
// MyArticleEditPage 为已登录用户自己的文章渲染编辑表单。
|
||||
func MyArticleEditPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
@@ -81,31 +82,34 @@ func MyArticleEditPage(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// MyArticleUpdate handles the POST request to update the user's own article.
|
||||
// MyArticleUpdate 处理更新用户自己文章的请求(带 author_id 所有权约束)。
|
||||
func MyArticleUpdate(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
id := c.Param("id")
|
||||
id := parseUintParam(c, "id")
|
||||
if id == 0 {
|
||||
APIError(c, http.StatusBadRequest, "api_invalid_request")
|
||||
return
|
||||
}
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
|
||||
var article models.Article
|
||||
if err := db.First(&article, "id = ? AND author_id = ?", id, userID).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/my/articles")
|
||||
APIError(c, http.StatusNotFound, "article_not_found")
|
||||
return
|
||||
}
|
||||
|
||||
f := parseArticleForm(c)
|
||||
f.Action = "/my/articles/" + id + "/edit"
|
||||
f.TitleText = tr["article_edit_title"]
|
||||
f.ArticleID = article.ID
|
||||
f, ok := parseArticleFormJSON(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if f.Title == "" {
|
||||
renderMyArticleForm(c, db, f, tr["article_title_required"])
|
||||
APIError(c, http.StatusBadRequest, "article_title_required")
|
||||
return
|
||||
}
|
||||
if f.Content == "" {
|
||||
renderMyArticleForm(c, db, f, tr["article_content_required"])
|
||||
APIError(c, http.StatusBadRequest, "article_content_required")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -118,13 +122,13 @@ func MyArticleUpdate(db *gorm.DB) gin.HandlerFunc {
|
||||
|
||||
newStatus := statusFromForm(f.StatusStr)
|
||||
|
||||
// Handle published_at: use form value if provided, otherwise auto-stamp on first publish.
|
||||
// 处理 published_at:若提供了表单值则使用,否则在首次发布时自动盖章。
|
||||
var publishedAt *time.Time
|
||||
if f.PublishedAt != "" {
|
||||
// User provided a custom published time
|
||||
// 用户提供了自定义发布时间
|
||||
publishedAt = parsePublishedAt(f.PublishedAt)
|
||||
} else {
|
||||
// Stamp the publish time the first time an article is published.
|
||||
// 文章首次发布时盖上发布时间。
|
||||
wasPublished := article.Status == models.ArticlePublished
|
||||
publishedAt = article.PublishedAt
|
||||
if newStatus == models.ArticlePublished && !wasPublished && publishedAt == nil {
|
||||
@@ -134,38 +138,46 @@ func MyArticleUpdate(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"title": f.Title,
|
||||
"slug": f.Slug,
|
||||
"summary": f.Summary,
|
||||
"content": f.Content,
|
||||
"cover": f.Cover,
|
||||
"status": newStatus,
|
||||
"is_top": f.IsTop,
|
||||
"title": f.Title,
|
||||
"slug": f.Slug,
|
||||
"summary": f.Summary,
|
||||
"content": f.Content,
|
||||
"cover": f.Cover,
|
||||
"status": newStatus,
|
||||
// SECURITY_TODO #31:作者不可修改置顶状态——保留库中
|
||||
// 现有值(管理员授权的置顶不因作者编辑而丢失,作者也无法
|
||||
// 自行置顶/取消置顶)。
|
||||
"is_top": article.IsTop,
|
||||
"published_at": publishedAt,
|
||||
}
|
||||
|
||||
if err := db.Model(&article).Updates(updates).Error; err != nil {
|
||||
renderMyArticleForm(c, db, f, tr["article_error"])
|
||||
APIError(c, http.StatusInternalServerError, "article_error")
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/my/articles")
|
||||
// 注意:my 表单无 tags 字段,此处不触碰标签(与旧行为一致)。
|
||||
APIOK(c, "/my/articles", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MyArticleDelete soft-deletes the user's own article.
|
||||
// MyArticleDelete 软删除用户自己的文章。
|
||||
func MyArticleDelete(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
id := parseUintParam(c, "id")
|
||||
if id == 0 {
|
||||
APIError(c, http.StatusBadRequest, "api_invalid_request")
|
||||
return
|
||||
}
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
|
||||
db.Where("id = ? AND author_id = ?", id, userID).Delete(&models.Article{})
|
||||
c.Redirect(http.StatusFound, "/my/articles")
|
||||
APIOK(c, "/my/articles", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// renderMyArticleForm renders the article form for regular users.
|
||||
// renderMyArticleForm 为普通用户渲染文章表单。
|
||||
func renderMyArticleForm(c *gin.Context, db *gorm.DB, f articleForm, errMsg string) {
|
||||
data := DefaultData(c)
|
||||
data["Title"] = f.TitleText
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// postForm 是表单编码请求的小助手,始终携带 CSRF 令牌。
|
||||
func postForm(e *securityTestEnv, method, path, cookie, csrfToken string, fields url.Values) *httptest.ResponseRecorder {
|
||||
if fields == nil {
|
||||
fields = url.Values{}
|
||||
}
|
||||
if csrfToken != "" {
|
||||
fields.Set("_csrf", csrfToken)
|
||||
}
|
||||
return e.do(method, path, cookie, strings.NewReader(fields.Encode()), "application/x-www-form-urlencoded")
|
||||
}
|
||||
|
||||
// TestStorageDirTraversalRejected 覆盖 SECURITY_TODO #22:管理员不得将
|
||||
// storage_dir 设置为逃逸出存储根目录的值。
|
||||
func TestStorageDirTraversalRejected(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
admin := e.login(t, "admin")
|
||||
token := e.csrfTokenFor(t, admin)
|
||||
|
||||
cases := []string{
|
||||
"../evil",
|
||||
"foo/bar",
|
||||
"a\\b",
|
||||
"/abs/path",
|
||||
"..",
|
||||
".",
|
||||
}
|
||||
for _, dir := range cases {
|
||||
w := postJSON(e, http.MethodPost, "/api/admin/settings/upload", admin, token,
|
||||
gin.H{"action": "save_config", "storage_dir": dir})
|
||||
if w.Code != http.StatusBadRequest || respCode(w) != "settings_upload_illegal_dir" {
|
||||
t.Fatalf("storage_dir %q: status = %d, code = %q, want 400/settings_upload_illegal_dir",
|
||||
dir, w.Code, respCode(w))
|
||||
}
|
||||
// 存储的值必须保持不变。
|
||||
var u models.UploadConfig
|
||||
if err := e.db.First(&u, 1).Error; err != nil {
|
||||
t.Fatalf("load upload config: %v", err)
|
||||
}
|
||||
if u.StorageDir != "attachments" {
|
||||
t.Fatalf("storage_dir %q: persisted value = %q, want unchanged \"attachments\"", dir, u.StorageDir)
|
||||
}
|
||||
}
|
||||
|
||||
// 安全的单段值可被接受。
|
||||
w := postJSON(e, http.MethodPost, "/api/admin/settings/upload", admin, token,
|
||||
gin.H{"action": "save_config", "storage_dir": "my_attach-2"})
|
||||
if w.Code != http.StatusOK || !respOK(w) {
|
||||
t.Fatalf("safe storage_dir: status = %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
var u models.UploadConfig
|
||||
if err := e.db.First(&u, 1).Error; err != nil {
|
||||
t.Fatalf("load upload config: %v", err)
|
||||
}
|
||||
if u.StorageDir != "my_attach-2" {
|
||||
t.Fatalf("persisted storage_dir = %q, want my_attach-2", u.StorageDir)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfilePasswordMinLength 覆盖个人资料密码修改路径上的
|
||||
// SECURITY_TODO #23。
|
||||
func TestProfilePasswordMinLength(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
alice := e.login(t, "alice")
|
||||
token := e.csrfTokenFor(t, alice)
|
||||
|
||||
// 1 个字符的密码必须被拒绝,旧哈希保持不变。
|
||||
w := postJSON(e, http.MethodPost, "/api/profile", alice, token,
|
||||
gin.H{"current_password": "pw-alice", "new_password": "a"})
|
||||
if w.Code != http.StatusBadRequest || respCode(w) != "profile_password_short" {
|
||||
t.Fatalf("short password: status = %d, code = %q, want 400/profile_password_short",
|
||||
w.Code, respCode(w))
|
||||
}
|
||||
var u models.User
|
||||
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
|
||||
t.Fatalf("load alice: %v", err)
|
||||
}
|
||||
if !u.CheckPassword("pw-alice") {
|
||||
t.Fatal("old password no longer verifies after rejected change")
|
||||
}
|
||||
|
||||
// 6 个字符的密码可被接受。
|
||||
w = postJSON(e, http.MethodPost, "/api/profile", alice, token,
|
||||
gin.H{"current_password": "pw-alice", "new_password": "newpass6"})
|
||||
if w.Code != http.StatusOK || !respOK(w) {
|
||||
t.Fatalf("valid password change: status = %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
|
||||
t.Fatalf("reload alice: %v", err)
|
||||
}
|
||||
if !u.CheckPassword("newpass6") || u.CheckPassword("pw-alice") {
|
||||
t.Fatal("password change did not take effect")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileEmailValidation 覆盖个人资料路径上的 SECURITY_TODO #24。
|
||||
func TestProfileEmailValidation(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
alice := e.login(t, "alice")
|
||||
token := e.csrfTokenFor(t, alice)
|
||||
|
||||
w := postJSON(e, http.MethodPost, "/api/profile", alice, token,
|
||||
gin.H{"email": "not-an-email"})
|
||||
if w.Code != http.StatusBadRequest || respCode(w) != "profile_email_invalid" {
|
||||
t.Fatalf("invalid email: status = %d, code = %q", w.Code, respCode(w))
|
||||
}
|
||||
var u models.User
|
||||
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
|
||||
t.Fatalf("load alice: %v", err)
|
||||
}
|
||||
if u.Email != "" {
|
||||
t.Fatalf("invalid email was persisted: %q", u.Email)
|
||||
}
|
||||
|
||||
w = postJSON(e, http.MethodPost, "/api/profile", alice, token,
|
||||
gin.H{"email": "alice@example.com"})
|
||||
if w.Code != http.StatusOK || !respOK(w) {
|
||||
t.Fatalf("valid email: status = %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
|
||||
t.Fatalf("reload alice: %v", err)
|
||||
}
|
||||
if u.Email != "alice@example.com" {
|
||||
t.Fatalf("valid email not persisted: %q", u.Email)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminUserPasswordAndEmailEnforcement 覆盖后台用户创建/更新路径上的
|
||||
// SECURITY_TODO #23/#24。
|
||||
func TestAdminUserPasswordAndEmailEnforcement(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
admin := e.login(t, "admin")
|
||||
token := e.csrfTokenFor(t, admin)
|
||||
|
||||
// 创建:短密码被拒绝(不创建任何行)。
|
||||
create := gin.H{"username": "charlie", "password": "ab", "role": models.RoleAuthor}
|
||||
w := postJSON(e, http.MethodPost, "/api/admin/users", admin, token, create)
|
||||
if w.Code != http.StatusBadRequest || respCode(w) != "user_password_short" {
|
||||
t.Fatalf("create with short password: status = %d, code = %q, want 400/user_password_short",
|
||||
w.Code, respCode(w))
|
||||
}
|
||||
|
||||
// 创建:非法邮箱被拒绝。
|
||||
create["password"] = "longenough"
|
||||
create["email"] = "abc"
|
||||
w = postJSON(e, http.MethodPost, "/api/admin/users", admin, token, create)
|
||||
if w.Code != http.StatusBadRequest || respCode(w) != "user_email_invalid" {
|
||||
t.Fatalf("create with invalid email: status = %d, code = %q", w.Code, respCode(w))
|
||||
}
|
||||
|
||||
var count int64
|
||||
e.db.Model(&models.User{}).Where("username = ?", "charlie").Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal("charlie was created despite invalid input")
|
||||
}
|
||||
|
||||
// 创建:合法数据成功。
|
||||
create["email"] = "charlie@example.com"
|
||||
w = postJSON(e, http.MethodPost, "/api/admin/users", admin, token, create)
|
||||
if w.Code != http.StatusOK || !respOK(w) {
|
||||
t.Fatalf("create valid user: status = %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
e.db.Model(&models.User{}).Where("username = ?", "charlie").Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatal("charlie was not created")
|
||||
}
|
||||
|
||||
// 更新(密码重置路径):短密码被拒绝,哈希保持不变。
|
||||
aliceID := userIDByUsername(t, e.db, "alice")
|
||||
w = postJSON(e, http.MethodPut, fmt.Sprintf("/api/admin/users/%d", aliceID), admin, token,
|
||||
gin.H{"password": "x", "role": models.RoleAuthor})
|
||||
if w.Code != http.StatusBadRequest || respCode(w) != "user_password_short" {
|
||||
t.Fatalf("update with short password: status = %d, code = %q", w.Code, respCode(w))
|
||||
}
|
||||
var u models.User
|
||||
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
|
||||
t.Fatalf("load alice: %v", err)
|
||||
}
|
||||
if !u.CheckPassword("pw-alice") {
|
||||
t.Fatal("alice password changed by a rejected reset")
|
||||
}
|
||||
|
||||
// 更新:非法邮箱被拒绝,旧值保留。
|
||||
w = postJSON(e, http.MethodPut, fmt.Sprintf("/api/admin/users/%d", aliceID), admin, token,
|
||||
gin.H{"email": "bad", "role": models.RoleAuthor})
|
||||
if w.Code != http.StatusBadRequest || respCode(w) != "user_email_invalid" {
|
||||
t.Fatalf("update with invalid email: status = %d, code = %q", w.Code, respCode(w))
|
||||
}
|
||||
e.db.Where("username = ?", "alice").First(&u)
|
||||
if u.Email != "" {
|
||||
t.Fatalf("invalid admin-set email persisted: %q", u.Email)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterRejectsInvalidEmail 覆盖注册上的 SECURITY_TODO #24。
|
||||
func TestRegisterRejectsInvalidEmail(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
if err := e.db.Model(&models.SiteSetting{}).Where("id = ?", 1).Update("allow_registration", true).Error; err != nil {
|
||||
t.Fatalf("enable registration: %v", err)
|
||||
}
|
||||
|
||||
// 获取注册表单以获得匿名 CSRF 令牌 + 会话。
|
||||
req := httptest.NewRequest(http.MethodGet, "/register", nil)
|
||||
w := httptest.NewRecorder()
|
||||
e.router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /register: status = %d", w.Code)
|
||||
}
|
||||
m := csrfTokenRe.FindStringSubmatch(w.Body.String())
|
||||
if m == nil {
|
||||
t.Fatal("register page did not render a CSRF token")
|
||||
}
|
||||
anonCookie := e.sessionCookie(w)
|
||||
|
||||
fields := gin.H{
|
||||
"username": "carol",
|
||||
"password": "secret1",
|
||||
"confirm_password": "secret1",
|
||||
"email": "abc",
|
||||
}
|
||||
w2 := postJSON(e, http.MethodPost, "/api/auth/register", anonCookie, m[1], fields)
|
||||
if w2.Code != http.StatusBadRequest {
|
||||
t.Fatalf("register invalid email: status = %d, want 400", w2.Code)
|
||||
}
|
||||
if code := respCode(w2); code != "register_email_invalid" {
|
||||
t.Fatalf("register invalid email: code = %q, want register_email_invalid", code)
|
||||
}
|
||||
var count int64
|
||||
e.db.Model(&models.User{}).Where("username = ?", "carol").Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal("carol created with invalid email")
|
||||
}
|
||||
|
||||
fields["email"] = "carol@example.com"
|
||||
w2 = postJSON(e, http.MethodPost, "/api/auth/register", anonCookie, m[1], fields)
|
||||
if w2.Code != http.StatusOK || !respOK(w2) {
|
||||
t.Fatalf("register valid email: status = %d, body %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
e.db.Model(&models.User{}).Where("username = ?", "carol").Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatal("carol not created")
|
||||
}
|
||||
}
|
||||
|
||||
// limiterEntryKey 通过扫描已跟踪条目返回某用户名的限流器键
|
||||
// (前缀的客户端 IP 取决于测试传输方式)。
|
||||
func limiterEntryKey(e *securityTestEnv, username string) string {
|
||||
for k := range e.limiter.entries {
|
||||
if strings.HasSuffix(k, "\x00"+username) {
|
||||
return k
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestLoginRateLimited 覆盖 SECURITY_TODO #10:重复失败会锁定
|
||||
// IP+用户名键,成功登录后重置。
|
||||
func TestLoginRateLimited(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
|
||||
loginAttempt := func(body gin.H) (*httptest.ResponseRecorder, string) {
|
||||
// 每次尝试使用全新的匿名会话(和 CSRF 令牌)。
|
||||
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
w := httptest.NewRecorder()
|
||||
e.router.ServeHTTP(w, req)
|
||||
m := csrfTokenRe.FindStringSubmatch(w.Body.String())
|
||||
if m == nil {
|
||||
t.Fatal("login page did not render a CSRF token")
|
||||
}
|
||||
anonCookie := e.sessionCookie(w)
|
||||
w2 := postJSON(e, http.MethodPost, "/api/auth/login", anonCookie, m[1], body)
|
||||
return w2, anonCookie
|
||||
}
|
||||
|
||||
bad := gin.H{"username": "alice", "password": "wrong-password"}
|
||||
for i := 0; i < maxLoginFailures; i++ {
|
||||
w, _ := loginAttempt(bad)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("attempt %d: status = %d, want 401", i+1, w.Code)
|
||||
}
|
||||
if code := respCode(w); code != "login_error" {
|
||||
t.Fatalf("attempt %d: code = %q, want login_error", i+1, code)
|
||||
}
|
||||
}
|
||||
|
||||
// 下一次尝试(即使密码正确)也会被锁定。
|
||||
good := gin.H{"username": "alice", "password": "pw-alice"}
|
||||
w, _ := loginAttempt(good)
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("locked attempt: status = %d, want 429", w.Code)
|
||||
}
|
||||
if code := respCode(w); code != "login_locked" {
|
||||
t.Fatalf("locked attempt: code = %q, want login_locked", code)
|
||||
}
|
||||
|
||||
// 不同的键(用户名)不受影响。
|
||||
w, _ = loginAttempt(gin.H{"username": "bob", "password": "pw-bob"})
|
||||
if w.Code != http.StatusOK || respRedirect(w) != "/" {
|
||||
t.Fatalf("different user login during lock: status = %d, redirect = %q",
|
||||
w.Code, respRedirect(w))
|
||||
}
|
||||
|
||||
// 重置后,被锁定的键再次可用。
|
||||
aliceKey := limiterEntryKey(e, "alice")
|
||||
if aliceKey == "" {
|
||||
t.Fatal("alice rate-limit entry not found")
|
||||
}
|
||||
e.limiter.Reset(aliceKey)
|
||||
w, _ = loginAttempt(good)
|
||||
if w.Code != http.StatusOK || respRedirect(w) != "/" {
|
||||
t.Fatalf("login after reset: status = %d, redirect = %q",
|
||||
w.Code, respRedirect(w))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginTimingDoesNotRevealUser 断言 SECURITY_TODO #25 的结构性保证:
|
||||
// 未知用户名仍执行一次 bcrypt 比较(虚拟哈希)并记录一次失败,
|
||||
// 因此两个分支在设计上耗时不可区分。
|
||||
func TestLoginTimingDoesNotRevealUser(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
w := httptest.NewRecorder()
|
||||
e.router.ServeHTTP(w, req)
|
||||
m := csrfTokenRe.FindStringSubmatch(w.Body.String())
|
||||
if m == nil {
|
||||
t.Fatal("login page did not render a CSRF token")
|
||||
}
|
||||
anonCookie := e.sessionCookie(w)
|
||||
|
||||
w2 := postJSON(e, http.MethodPost, "/api/auth/login", anonCookie, m[1],
|
||||
gin.H{"username": "does-not-exist-31415", "password": "anything"})
|
||||
if w2.Code != http.StatusUnauthorized || respCode(w2) != "login_error" {
|
||||
t.Fatalf("unknown user: status = %d, code = %q", w2.Code, respCode(w2))
|
||||
}
|
||||
|
||||
// 未知用户的键必须被计入失败次数(若限流器共享),
|
||||
// 证明该分支走过了 Fail + 虚拟 bcrypt 路径。
|
||||
if key := limiterEntryKey(e, "does-not-exist-31415"); key == "" {
|
||||
t.Fatal("unknown-user branch did not record a failure")
|
||||
} else if e.limiter.entries[key].failures != 1 {
|
||||
t.Fatalf("unknown-user failure count = %d, want 1", e.limiter.entries[key].failures)
|
||||
}
|
||||
}
|
||||
func TestSafeStorageDirNameAndValidators(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
dir string
|
||||
ok bool
|
||||
}{
|
||||
{"attachments", true},
|
||||
{"my_attach-2", true},
|
||||
{"A1-_", true},
|
||||
{"", false},
|
||||
{"../evil", false},
|
||||
{"foo/bar", false},
|
||||
{"a\\b", false},
|
||||
{"/abs", false},
|
||||
{"..", false},
|
||||
{"a b", false},
|
||||
{".hidden", false},
|
||||
} {
|
||||
if got := safeStorageDirName(tc.dir); got != tc.ok {
|
||||
t.Errorf("safeStorageDirName(%q) = %v, want %v", tc.dir, got, tc.ok)
|
||||
}
|
||||
}
|
||||
|
||||
if validatePassword("12345") {
|
||||
t.Error("validatePassword accepted 5 chars")
|
||||
}
|
||||
if !validatePassword("123456") {
|
||||
t.Error("validatePassword rejected 6 chars")
|
||||
}
|
||||
if !validateEmail("") || !validateEmail("user@example.com") {
|
||||
t.Error("validateEmail rejected empty or valid address")
|
||||
}
|
||||
if validateEmail("abc") || validateEmail("a@b@c") {
|
||||
t.Error("validateEmail accepted malformed address")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// TestUploadAttachmentRejectsMismatchedContent 覆盖 SECURITY_TODO #14:
|
||||
// 扩展名白名单仅是头部级别的;字节必须与配置的 MIME 类型匹配
|
||||
//(携带 PNG 字节的 .txt 文件是伪装载荷)。
|
||||
func TestUploadAttachmentRejectsMismatchedContent(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
alice := e.login(t, "alice")
|
||||
token := e.csrfTokenFor(t, alice)
|
||||
|
||||
var aliceArt models.Article
|
||||
e.db.Where("slug = ?", "alice-post").First(&aliceArt)
|
||||
artID := strconv.FormatUint(uint64(aliceArt.ID), 10)
|
||||
|
||||
// 声称 .txt、实际 PNG 字节 -> 拒绝 400。
|
||||
var buf strings.Builder
|
||||
mw := multipart.NewWriter(&buf)
|
||||
mw.WriteField("article_id", artID)
|
||||
mw.WriteField("_csrf", token)
|
||||
fw, _ := mw.CreateFormFile("file", "photo.txt")
|
||||
fw.Write(pngBytes(t))
|
||||
mw.Close()
|
||||
w := e.do(http.MethodPost, "/api/my/articles/attachments", alice, strings.NewReader(buf.String()), mw.FormDataContentType())
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("mismatched content: status = %d, want 400 (body %s)", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 真正的文本可通过。
|
||||
buf.Reset()
|
||||
mw = multipart.NewWriter(&buf)
|
||||
mw.WriteField("article_id", artID)
|
||||
mw.WriteField("_csrf", token)
|
||||
fw, _ = mw.CreateFormFile("file", "notes.txt")
|
||||
fw.Write([]byte("hello plain text"))
|
||||
mw.Close()
|
||||
w = e.do(http.MethodPost, "/api/my/articles/attachments", alice, strings.NewReader(buf.String()), mw.FormDataContentType())
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("genuine text upload: status = %d, want 200 (body %s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRSSUsesConfiguredSiteURL 覆盖 SECURITY_TODO #16:配置后 feed 链接使用
|
||||
// 规范化的站点 URL,否则回退到请求的 Host(并输出日志警告)。
|
||||
func TestRSSUsesConfiguredSiteURL(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
|
||||
// 未设置:回退到请求的 Host。
|
||||
req := httptest.NewRequest(http.MethodGet, "/rss", nil)
|
||||
req.Host = "evil.example.com"
|
||||
w := httptest.NewRecorder()
|
||||
e.router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /rss: status = %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "http://evil.example.com") {
|
||||
t.Fatal("fallback did not use the request Host")
|
||||
}
|
||||
|
||||
// 已配置:固定 URL 生效,Host 头被忽略。
|
||||
var s models.SiteSetting
|
||||
if err := e.db.First(&s, 1).Error; err != nil {
|
||||
t.Fatalf("load site setting: %v", err)
|
||||
}
|
||||
s.SiteURL = "https://blog.example.com"
|
||||
if err := e.db.Save(&s).Error; err != nil {
|
||||
t.Fatalf("save site setting: %v", err)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/rss", nil)
|
||||
req.Host = "evil.example.com"
|
||||
w = httptest.NewRecorder()
|
||||
e.router.ServeHTTP(w, req)
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "https://blog.example.com") {
|
||||
t.Fatal("configured SiteURL was not used in the feed")
|
||||
}
|
||||
if strings.Contains(body, "evil.example.com") {
|
||||
t.Fatal("request Host leaked into RSS link despite SiteURL being set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCommentListFollowsGravatarSwitch 覆盖 SECURITY_TODO #15:
|
||||
// 平台开关关闭时后台审核列表不输出 Gravatar URL,
|
||||
// 管理员重新启用后再使用。
|
||||
func TestAdminCommentListFollowsGravatarSwitch(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
admin := e.login(t, "admin")
|
||||
|
||||
var art models.Article
|
||||
e.db.Where("slug = ?", "alice-post").First(&art)
|
||||
e.db.Create(&models.Comment{
|
||||
ArticleID: art.ID, AuthorName: "Ann", Email: "ann@example.com",
|
||||
Content: "hello", Status: models.CommentApproved, IPAddress: "127.0.0.1",
|
||||
})
|
||||
|
||||
// 关闭(新默认):列表中没有 gravatar.com 条目。
|
||||
w := e.do(http.MethodGet, "/admin/comments?status=all", admin, nil, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /admin/comments: status = %d", w.Code)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "gravatar.com") {
|
||||
t.Fatal("admin comment list emitted Gravatar URLs while disabled")
|
||||
}
|
||||
|
||||
// 开启:Gravatar URL 出现(遵循平台策略)。
|
||||
e.db.Model(&models.CommentConfig{}).Where("id = ?", 1).Update("use_gravatar", true)
|
||||
models.LoadConfigCache(e.db)
|
||||
w = e.do(http.MethodGet, "/admin/comments?status=all", admin, nil, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /admin/comments (enabled): status = %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "gravatar.com") {
|
||||
t.Fatal("admin comment list missing Gravatar URLs while enabled")
|
||||
}
|
||||
}
|
||||
// TestContentMatchesTypeTable 驱动纯匹配函数(SECURITY_TODO #14)。
|
||||
func TestContentMatchesTypeTable(t *testing.T) {
|
||||
txt := &models.UploadFileType{MimeType: "text/plain"}
|
||||
pngType := &models.UploadFileType{MimeType: "image/png"}
|
||||
noPolicy := &models.UploadFileType{MimeType: ""}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
typ *models.UploadFileType
|
||||
content []byte
|
||||
want bool
|
||||
}{
|
||||
{"txt-real", txt, []byte("just text content"), true},
|
||||
{"txt-png-bytes", txt, pngBytes(t), false},
|
||||
{"png-real", pngType, pngBytes(t), true},
|
||||
{"png-text-bytes", pngType, []byte("not an image at all"), false},
|
||||
{"empty-policy", noPolicy, pngBytes(t), true},
|
||||
{"empty-content", txt, nil, true},
|
||||
{"octet-stream-wildcard", &models.UploadFileType{MimeType: "application/octet-stream"}, pngBytes(t), true},
|
||||
{"charset-parameter", &models.UploadFileType{MimeType: "text/plain; charset=utf-8"}, []byte("abc"), true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := contentMatchesType(tc.typ, tc.content); got != tc.want {
|
||||
t.Fatalf("contentMatchesType(%q) = %v, want %v", tc.typ.MimeType, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// TestMyArticlesCannotPin 覆盖 SECURITY_TODO #31:普通作者经由 /api/my/articles
|
||||
// 提交 is_top=true 时落库为 false;admin 路径不受影响;作者编辑已置顶文章
|
||||
// 不会丢失管理员授权的置顶状态。
|
||||
func TestMyArticlesCannotPin(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
|
||||
// 作者创建:is_top=true 被降级为 false。
|
||||
alice := e.login(t, "alice")
|
||||
aliceToken := e.csrfTokenFor(t, alice)
|
||||
w := postJSON(e, http.MethodPost, "/api/my/articles", alice, aliceToken, gin.H{
|
||||
"title": "pinned attempt", "content": "body", "slug": "pinned-attempt",
|
||||
"status": "1", "is_top": true,
|
||||
})
|
||||
if w.Code != http.StatusOK || !respOK(w) {
|
||||
t.Fatalf("author create: status = %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
var art models.Article
|
||||
if err := e.db.Where("slug = ?", "pinned-attempt").First(&art).Error; err != nil {
|
||||
t.Fatalf("load created article: %v", err)
|
||||
}
|
||||
if art.IsTop {
|
||||
t.Fatal("author-created article must not be pinned")
|
||||
}
|
||||
|
||||
// 作者编辑:提交 is_top=true 不生效(保持 false)。
|
||||
w = postJSON(e, http.MethodPut, "/api/my/articles/"+itoa(art.ID), alice, aliceToken, gin.H{
|
||||
"title": "pinned attempt", "content": "body v2", "slug": "pinned-attempt",
|
||||
"status": "1", "is_top": true,
|
||||
})
|
||||
if w.Code != http.StatusOK || !respOK(w) {
|
||||
t.Fatalf("author update: status = %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := e.db.First(&art, art.ID).Error; err != nil {
|
||||
t.Fatalf("reload article: %v", err)
|
||||
}
|
||||
if art.IsTop {
|
||||
t.Fatal("author update must not pin the article")
|
||||
}
|
||||
|
||||
// admin 路径不受影响。
|
||||
admin := e.login(t, "admin")
|
||||
adminToken := e.csrfTokenFor(t, admin)
|
||||
w = postJSON(e, http.MethodPut, "/api/admin/articles/"+itoa(art.ID), admin, adminToken, gin.H{
|
||||
"title": "pinned attempt", "content": "body v2", "slug": "pinned-attempt",
|
||||
"status": "1", "is_top": true,
|
||||
})
|
||||
if w.Code != http.StatusOK || !respOK(w) {
|
||||
t.Fatalf("admin update: status = %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := e.db.First(&art, art.ID).Error; err != nil {
|
||||
t.Fatalf("reload article after admin pin: %v", err)
|
||||
}
|
||||
if !art.IsTop {
|
||||
t.Fatal("admin path must still be able to pin")
|
||||
}
|
||||
|
||||
// 管理员授权置顶后:作者编辑保留置顶状态(不会丢失,也不能取消)。
|
||||
w = postJSON(e, http.MethodPut, "/api/my/articles/"+itoa(art.ID), alice, aliceToken, gin.H{
|
||||
"title": "pinned attempt", "content": "body v3", "slug": "pinned-attempt",
|
||||
"status": "1", "is_top": false,
|
||||
})
|
||||
if w.Code != http.StatusOK || !respOK(w) {
|
||||
t.Fatalf("author update on pinned article: status = %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := e.db.First(&art, art.ID).Error; err != nil {
|
||||
t.Fatalf("reload article: %v", err)
|
||||
}
|
||||
if !art.IsTop {
|
||||
t.Fatal("author edit must not clear an admin-granted pin")
|
||||
}
|
||||
}
|
||||
+87
-93
@@ -19,10 +19,15 @@ import (
|
||||
xdraw "golang.org/x/image/draw"
|
||||
_ "golang.org/x/image/webp"
|
||||
|
||||
// 注册 processAvatar 依赖的解码器。JPEG 由上面的 image/jpeg 导入注册;
|
||||
// png/gif 必须空导入,否则 image.Decode 会拒绝它们。
|
||||
_ "image/gif"
|
||||
_ "image/png"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// ProfilePage renders the profile edit page.
|
||||
// ProfilePage 渲染个人资料编辑页面。
|
||||
func ProfilePage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
@@ -39,7 +44,7 @@ func ProfilePage(db *gorm.DB) gin.HandlerFunc {
|
||||
data["Title"] = tr["profile_title"]
|
||||
data["Profile"] = user
|
||||
|
||||
// Flash messages (success / error).
|
||||
// Flash 消息(成功 / 错误)。
|
||||
if msg := c.Query("saved"); msg == "1" {
|
||||
data["Success"] = tr["profile_saved"]
|
||||
}
|
||||
@@ -53,13 +58,28 @@ func ProfilePage(db *gorm.DB) gin.HandlerFunc {
|
||||
data["Error"] = tr["profile_upload_disabled"]
|
||||
case "size":
|
||||
data["Error"] = fmt.Sprintf(tr["profile_upload_too_large"], c.Query("max"))
|
||||
case "pw_short":
|
||||
data["Error"] = tr["profile_password_short"]
|
||||
case "email":
|
||||
data["Error"] = tr["profile_email_invalid"]
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "profile", data)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateProfile processes the profile edit form (multipart).
|
||||
// profileRequest 是 POST /api/profile 的 JSON 请求体。
|
||||
// 头像文件上传走 POST /api/profile/avatar(multipart)。
|
||||
type profileRequest struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
Gender string `json:"gender"`
|
||||
Email string `json:"email"`
|
||||
Birthday string `json:"birthday"` // YYYY-MM-DD
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// UpdateProfile 处理个人资料编辑(JSON)。
|
||||
func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
@@ -67,113 +87,72 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/profile")
|
||||
APIError(c, http.StatusNotFound, "user_not_found")
|
||||
return
|
||||
}
|
||||
|
||||
// --- Text fields ---
|
||||
// Allow empty display_name (user can clear it to fall back to username)
|
||||
user.DisplayName = strings.TrimSpace(c.PostForm("display_name"))
|
||||
var req profileRequest
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if v := c.PostForm("gender"); v != "" {
|
||||
// --- 文本字段 ---
|
||||
// 允许 display_name 为空(用户可清空以回退到用户名)
|
||||
user.DisplayName = strings.TrimSpace(req.DisplayName)
|
||||
|
||||
if v := strings.TrimSpace(req.Gender); v != "" {
|
||||
user.Gender = v
|
||||
}
|
||||
if v := c.PostForm("email"); v != "" {
|
||||
// SECURITY (#24):持久化前校验邮箱格式
|
||||
//(脏值会污染 Gravatar 查询)。允许为空。
|
||||
if v := strings.TrimSpace(req.Email); v != "" {
|
||||
if !validateEmail(v) {
|
||||
APIError(c, http.StatusBadRequest, "profile_email_invalid")
|
||||
return
|
||||
}
|
||||
user.Email = v
|
||||
}
|
||||
if v := c.PostForm("birthday"); v != "" {
|
||||
if v := strings.TrimSpace(req.Birthday); v != "" {
|
||||
if t, err := time.Parse("2006-01-02", v); err == nil {
|
||||
user.Birthday = &t
|
||||
}
|
||||
}
|
||||
|
||||
// --- Avatar upload ---
|
||||
file, header, err := c.Request.FormFile("avatar")
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
|
||||
// Validate against the platform upload policy (switch + type + size).
|
||||
check := ValidateUpload(header)
|
||||
if !check.OK {
|
||||
session.Save()
|
||||
reason := "?error=upload"
|
||||
if !models.GetUploadConfig().Enabled {
|
||||
reason = "?error=upload_disabled"
|
||||
} else if check.Type != nil {
|
||||
reason = fmt.Sprintf("?error=size&max=%s", formatSize(check.MaxSize))
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/profile"+reason)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine file extension (validated to be in the whitelist).
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
|
||||
// Save under storagePath/avatars/.
|
||||
avatarDir := filepath.Join(storagePath, "avatars")
|
||||
if err := os.MkdirAll(avatarDir, 0755); err != nil {
|
||||
c.Redirect(http.StatusFound, "/profile")
|
||||
return
|
||||
}
|
||||
|
||||
// Remove old avatar file if it exists (different extension or same).
|
||||
if user.Avatar != "" {
|
||||
oldPath := filepath.Join(avatarDir, user.Avatar)
|
||||
os.Remove(oldPath) // ignore error — file may not exist
|
||||
}
|
||||
|
||||
// Use user ID as filename base.
|
||||
savedName := fmt.Sprintf("%d%s", user.ID, ext)
|
||||
savedPath := filepath.Join(avatarDir, savedName)
|
||||
|
||||
dst, err := os.Create(savedPath)
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, "/profile")
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
c.Redirect(http.StatusFound, "/profile")
|
||||
return
|
||||
}
|
||||
|
||||
user.Avatar = savedName
|
||||
session.Set("avatar", savedName)
|
||||
}
|
||||
|
||||
// --- Password change ---
|
||||
currentPass := c.PostForm("current_password")
|
||||
newPass := c.PostForm("new_password")
|
||||
// --- 密码修改 ---
|
||||
currentPass := req.CurrentPassword
|
||||
newPass := req.NewPassword
|
||||
if currentPass != "" && newPass != "" {
|
||||
if !user.CheckPassword(currentPass) {
|
||||
session.Save()
|
||||
c.Redirect(http.StatusFound, "/profile?error=pw")
|
||||
APIError(c, http.StatusBadRequest, "profile_wrong_password")
|
||||
return
|
||||
}
|
||||
// SECURITY (#23):执行与注册相同的最小长度;
|
||||
// 重置为 1 个字符的密码将极易被猜出。
|
||||
if !validatePassword(newPass) {
|
||||
APIError(c, http.StatusBadRequest, "profile_password_short")
|
||||
return
|
||||
}
|
||||
if err := user.SetPassword(newPass); err != nil {
|
||||
session.Save()
|
||||
c.Redirect(http.StatusFound, "/profile")
|
||||
APIError(c, http.StatusInternalServerError, "api_error")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Save user record.
|
||||
// 保存用户记录。
|
||||
if err := db.Save(&user).Error; err != nil {
|
||||
session.Save()
|
||||
c.Redirect(http.StatusFound, "/profile")
|
||||
APIError(c, http.StatusInternalServerError, "api_error")
|
||||
return
|
||||
}
|
||||
|
||||
// Update display name in session.
|
||||
// 更新会话中的显示名。
|
||||
session.Set("display_name", user.DisplayName)
|
||||
session.Save()
|
||||
|
||||
c.Redirect(http.StatusFound, "/profile?saved=1")
|
||||
APIOK(c, "/profile?saved=1", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// UploadAvatar handles AJAX avatar upload with cropping.
|
||||
// UploadAvatar 处理带裁剪的 AJAX 头像上传。
|
||||
func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
@@ -192,7 +171,7 @@ func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Validate against the platform upload policy (switch + type + size).
|
||||
// 依据平台上传策略校验(总开关 + 类型 + 大小)。
|
||||
check := ValidateUpload(header)
|
||||
if !check.OK {
|
||||
if !models.GetUploadConfig().Enabled {
|
||||
@@ -207,35 +186,50 @@ func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine file extension (validated to be in the whitelist).
|
||||
// SECURITY (#21):头像必须是白名单中的图片类型——
|
||||
// 仅靠扩展名白名单(管理员可配置)可能让活动内容进入
|
||||
// /uploads/avatars/。
|
||||
if check.Type.Category != models.CategoryImage {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file type not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
// 确定文件扩展名(已校验在白名单内)。
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
|
||||
// Read file bytes for image processing.
|
||||
// 读取文件字节以进行图像处理。
|
||||
imgBytes, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read file"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decode, resize, and re-encode the image.
|
||||
processedBytes, finalExt, err := processAvatar(imgBytes, ext)
|
||||
if err != nil {
|
||||
// Fall back to saving raw bytes if processing fails.
|
||||
processedBytes = imgBytes
|
||||
finalExt = ext
|
||||
// SECURITY (#14):解码前进行魔数字节一致性校验。
|
||||
if !contentMatchesType(check.Type, imgBytes) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file content does not match its declared type"})
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure avatar directory exists.
|
||||
// 解码、缩放并重新编码图像。
|
||||
processedBytes, finalExt, err := processAvatar(imgBytes, ext)
|
||||
if err != nil {
|
||||
// SECURITY (#21):直接拒绝无法解码的载荷——存储原始字节
|
||||
// 会让非图像内容进入 avatars/ 目录。
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid image file"})
|
||||
return
|
||||
}
|
||||
|
||||
// 确保头像目录存在。
|
||||
avatarDir := filepath.Join(storagePath, "avatars")
|
||||
os.MkdirAll(avatarDir, 0755)
|
||||
|
||||
// Remove old avatar file.
|
||||
// 删除旧头像文件。
|
||||
if user.Avatar != "" {
|
||||
oldPath := filepath.Join(avatarDir, user.Avatar)
|
||||
os.Remove(oldPath)
|
||||
}
|
||||
|
||||
// Save processed avatar.
|
||||
// 保存处理后的头像。
|
||||
savedName := fmt.Sprintf("%d%s", user.ID, finalExt)
|
||||
savedPath := filepath.Join(avatarDir, savedName)
|
||||
if err := os.WriteFile(savedPath, processedBytes, 0644); err != nil {
|
||||
@@ -243,11 +237,11 @@ func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Update user record.
|
||||
// 更新用户记录。
|
||||
user.Avatar = savedName
|
||||
db.Save(&user)
|
||||
|
||||
// Update session.
|
||||
// 更新会话。
|
||||
session.Set("avatar", savedName)
|
||||
session.Save()
|
||||
|
||||
@@ -255,7 +249,7 @@ func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// processAvatar decodes, resizes to 256x256, and re-encodes an avatar image as JPEG.
|
||||
// processAvatar 解码头像图像,缩放到 256x256,并重新编码为 JPEG。
|
||||
func processAvatar(imgBytes []byte, ext string) ([]byte, string, error) {
|
||||
src, _, err := image.Decode(bytes.NewReader(imgBytes))
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 注册/评论的固定窗口限流参数(SECURITY_TODO #27/#28)。
|
||||
// 与 LoginRateLimiter 一样为进程内存实现:应用是单实例部署
|
||||
// (反向代理后的 unix socket),因此无需共享存储。
|
||||
const (
|
||||
registerLimitPerHour = 10 // 每 IP 每小时的注册上限
|
||||
registerWindow = time.Hour // 注册计数窗口
|
||||
commentLimitPerMin = 5 // 每 IP 每分钟的评论上限
|
||||
commentWindow = time.Minute // 评论计数窗口
|
||||
)
|
||||
|
||||
// WindowRateLimiter 是固定时间窗口计数限流器:键在窗口内最多计数 limit 次,
|
||||
// 超限时 Allow 返回 false。窗口过期后计数自动重置。
|
||||
type WindowRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*windowRateEntry
|
||||
limit int
|
||||
window time.Duration
|
||||
nowFn func() time.Time
|
||||
}
|
||||
|
||||
type windowRateEntry struct {
|
||||
count int
|
||||
windowStart time.Time
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// NewWindowLimiter 创建固定窗口限流器(limit 次/每 window)。
|
||||
func NewWindowLimiter(limit int, window time.Duration) *WindowRateLimiter {
|
||||
return &WindowRateLimiter{
|
||||
entries: make(map[string]*windowRateEntry),
|
||||
limit: limit,
|
||||
window: window,
|
||||
nowFn: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Allow 报告该键是否被允许再发一次请求(调用本身计入窗口计数;
|
||||
// 窗口过期时计数重置为 1)。
|
||||
func (l *WindowRateLimiter) Allow(key string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.nowFn()
|
||||
e, ok := l.entries[key]
|
||||
if !ok || now.Sub(e.windowStart) >= l.window {
|
||||
// 新窗口:重置计数。
|
||||
l.entries[key] = &windowRateEntry{count: 1, windowStart: now, lastSeen: now}
|
||||
l.sweep(now)
|
||||
return true
|
||||
}
|
||||
e.count++
|
||||
e.lastSeen = now
|
||||
l.sweep(now)
|
||||
return e.count <= l.limit
|
||||
}
|
||||
|
||||
// sweep 限制映射大小,防止攻击者通过大量键令限流器无限增长
|
||||
// (与 LoginRateLimiter.sweep 同一策略)。
|
||||
func (l *WindowRateLimiter) sweep(now time.Time) {
|
||||
if len(l.entries) <= maxTrackedKeys {
|
||||
return
|
||||
}
|
||||
// 第 1 轮:移除窗口已过且不再活跃的键。
|
||||
for k, e := range l.entries {
|
||||
if now.Sub(e.lastSeen) > l.window {
|
||||
delete(l.entries, k)
|
||||
}
|
||||
}
|
||||
if len(l.entries) <= maxTrackedKeys {
|
||||
return
|
||||
}
|
||||
// 第 2 轮:若仍然过大,按 lastSeen 逐出最旧的条目。
|
||||
cut := len(l.entries) - maxTrackedKeys + maxTrackedKeys/4
|
||||
var byOldest []struct {
|
||||
key string
|
||||
t time.Time
|
||||
}
|
||||
for k, e := range l.entries {
|
||||
byOldest = append(byOldest, struct {
|
||||
key string
|
||||
t time.Time
|
||||
}{k, e.lastSeen})
|
||||
}
|
||||
for i := 1; i < len(byOldest); i++ {
|
||||
for j := i; j > 0 && byOldest[j].t.Before(byOldest[j-1].t); j-- {
|
||||
byOldest[j], byOldest[j-1] = byOldest[j-1], byOldest[j]
|
||||
}
|
||||
}
|
||||
for _, o := range byOldest[:cut] {
|
||||
delete(l.entries, o.key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// TestWindowLimiterFixedWindow 单元测试固定窗口行为:窗口内超限拒绝、
|
||||
// 窗口过期后计数重置、不同键互不影响、键有界(sweep 生效)。
|
||||
func TestWindowLimiterFixedWindow(t *testing.T) {
|
||||
l := NewWindowLimiter(2, time.Minute)
|
||||
now := time.Unix(1_000_000, 0)
|
||||
l.nowFn = func() time.Time { return now }
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if !l.Allow("a") {
|
||||
t.Fatalf("attempt %d: expect allowed within limit", i+1)
|
||||
}
|
||||
}
|
||||
if l.Allow("a") {
|
||||
t.Fatal("expect blocked after limit")
|
||||
}
|
||||
// 其他键不受影响。
|
||||
if !l.Allow("b") {
|
||||
t.Fatal("different key must not be affected")
|
||||
}
|
||||
|
||||
// 窗口过期后计数重置。
|
||||
now = now.Add(time.Minute + time.Second)
|
||||
if !l.Allow("a") {
|
||||
t.Fatal("expect allowed after window rollover")
|
||||
}
|
||||
}
|
||||
|
||||
// guestSessionAndToken 取一个匿名会话及其 CSRF 令牌。
|
||||
func guestSessionAndToken(e *securityTestEnv) (string, string) {
|
||||
w := e.do(http.MethodGet, "/login", "", nil, "")
|
||||
m := csrfTokenRe.FindStringSubmatch(w.Body.String())
|
||||
if m == nil {
|
||||
return e.sessionCookie(w), ""
|
||||
}
|
||||
return e.sessionCookie(w), m[1]
|
||||
}
|
||||
|
||||
// postJSONFrom 与 postJSON 相同,但可指定客户端 RemoteAddr 以模拟不同来源 IP。
|
||||
func postJSONFrom(e *securityTestEnv, method, path, cookie, csrfToken, ip string, body interface{}) *httptest.ResponseRecorder {
|
||||
var buf bytes.Buffer
|
||||
_ = json.NewEncoder(&buf).Encode(body)
|
||||
req := httptest.NewRequest(method, path, &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if csrfToken != "" {
|
||||
req.Header.Set("X-CSRF-Token", csrfToken)
|
||||
}
|
||||
if cookie != "" {
|
||||
req.Header.Set("Cookie", cookie)
|
||||
}
|
||||
req.RemoteAddr = ip + ":4321"
|
||||
w := httptest.NewRecorder()
|
||||
e.router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// TestRegisterRateLimited 覆盖 SECURITY_TODO #27:同 IP 连续注册超过
|
||||
// 阈值(10 次/小时)后返回 429/register_locked;其他 IP 不受影响。
|
||||
func TestRegisterRateLimited(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
if err := e.db.Model(&models.SiteSetting{}).Where("id = ?", 1).Update("allow_registration", true).Error; err != nil {
|
||||
t.Fatalf("enable registration: %v", err)
|
||||
}
|
||||
|
||||
register := func(username, ip string) *httptest.ResponseRecorder {
|
||||
cookie, token := guestSessionAndToken(e)
|
||||
if token == "" {
|
||||
t.Fatal("login page did not render a CSRF token")
|
||||
}
|
||||
return postJSONFrom(e, http.MethodPost, "/api/auth/register", cookie, token, ip, gin.H{
|
||||
"username": username,
|
||||
"password": "secret1",
|
||||
"confirm_password": "secret1",
|
||||
"email": username + "@example.com",
|
||||
})
|
||||
}
|
||||
|
||||
const ipA = "198.51.100.10"
|
||||
for i := 0; i < registerLimitPerHour; i++ {
|
||||
w := register(fmt.Sprintf("reg%d", i), ipA)
|
||||
if w.Code != http.StatusOK || !respOK(w) {
|
||||
t.Fatalf("attempt %d: status = %d, body %s", i+1, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 下一次尝试(即使输入合法)被限流。
|
||||
w := register("reg-over", ipA)
|
||||
if w.Code != http.StatusTooManyRequests || respCode(w) != "register_locked" {
|
||||
t.Fatalf("rate-limited register: status = %d, code = %q, want 429/register_locked",
|
||||
w.Code, respCode(w))
|
||||
}
|
||||
|
||||
// 其他 IP 不受影响。
|
||||
w = register("reg-other", "198.51.100.11")
|
||||
if w.Code != http.StatusOK || !respOK(w) {
|
||||
t.Fatalf("register from other IP: status = %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommentRateLimited 覆盖 SECURITY_TODO #28:同 IP 高频提交评论超过
|
||||
// 阈值(5 条/分钟)后返回 429/comments_locked;其他 IP 不受影响。
|
||||
func TestCommentRateLimited(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
|
||||
comment := func(ip string) *httptest.ResponseRecorder {
|
||||
cookie, token := guestSessionAndToken(e)
|
||||
if token == "" {
|
||||
t.Fatal("login page did not render a CSRF token")
|
||||
}
|
||||
return postJSONFrom(e, http.MethodPost, "/api/article/alice-post/comments", cookie, token, ip, gin.H{
|
||||
"name": "guest",
|
||||
"email": "guest@example.com",
|
||||
"content": "nice post",
|
||||
})
|
||||
}
|
||||
|
||||
const ipA = "198.51.100.20"
|
||||
for i := 0; i < commentLimitPerMin; i++ {
|
||||
w := comment(ipA)
|
||||
if w.Code != http.StatusOK || !respOK(w) {
|
||||
t.Fatalf("comment %d: status = %d, body %s", i+1, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 第六次提交被限流。
|
||||
w := comment(ipA)
|
||||
if w.Code != http.StatusTooManyRequests || respCode(w) != "comments_locked" {
|
||||
t.Fatalf("rate-limited comment: status = %d, code = %q, want 429/comments_locked",
|
||||
w.Code, respCode(w))
|
||||
}
|
||||
|
||||
// 其他 IP 不受影响。
|
||||
w = comment("198.51.100.21")
|
||||
if w.Code != http.StatusOK || !respOK(w) {
|
||||
t.Fatalf("comment from other IP: status = %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
+38
-28
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -13,16 +14,16 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RSS 2.0 XML structure definitions.
|
||||
// RSS 2.0 XML 结构定义。
|
||||
|
||||
// RSS is the root element of an RSS 2.0 feed.
|
||||
// RSS 是 RSS 2.0 feed 的根元素。
|
||||
type RSS struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Version string `xml:"version,attr"`
|
||||
Channel *Channel `xml:"channel"`
|
||||
}
|
||||
|
||||
// Channel represents the RSS channel containing feed metadata and items.
|
||||
// Channel 表示包含 feed 元数据和条目(items)的 RSS channel。
|
||||
type Channel struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
@@ -32,7 +33,7 @@ type Channel struct {
|
||||
Items []Item `xml:"item"`
|
||||
}
|
||||
|
||||
// Item represents a single article in the RSS feed.
|
||||
// Item 表示 RSS feed 中的单篇文章。
|
||||
type Item struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
@@ -42,28 +43,37 @@ type Item struct {
|
||||
GUID string `xml:"guid"`
|
||||
}
|
||||
|
||||
// RSSFeed generates an RSS 2.0 feed of the latest published articles.
|
||||
// RSSFeed 生成最新已发布文章的 RSS 2.0 feed。
|
||||
func RSSFeed(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Determine the current language for site metadata.
|
||||
// 确定用于站点元数据的当前语言。
|
||||
lang, exists := c.Get("lang")
|
||||
if !exists {
|
||||
lang = "en"
|
||||
}
|
||||
langStr := lang.(string)
|
||||
|
||||
// Get site settings for feed metadata.
|
||||
// 获取站点设置用于 feed 元数据。
|
||||
siteSetting := &models.SiteSetting{}
|
||||
db.First(siteSetting)
|
||||
|
||||
// Construct the base URL from the request.
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
// SECURITY_TODO #16:配置后使用设置中的规范化站点 URL——
|
||||
// 请求的 Host 可被攻击者控制,否则会污染 feed 中的每个链接。
|
||||
// 对于旧部署则回退并给出警告。
|
||||
var baseURL string
|
||||
if u := strings.TrimSpace(siteSetting.SiteURL); u != "" {
|
||||
baseURL = strings.TrimRight(u, "/")
|
||||
} else {
|
||||
log.Printf("WARNING: Site URL is not set in settings; RSS links use request Host %q (set settings_site_url to a fixed URL)",
|
||||
c.Request.Host)
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
baseURL = fmt.Sprintf("%s://%s", scheme, c.Request.Host)
|
||||
}
|
||||
baseURL := fmt.Sprintf("%s://%s", scheme, c.Request.Host)
|
||||
|
||||
// Get the latest 20 published articles.
|
||||
// 获取最新 20 篇已发布文章。
|
||||
var articles []models.Article
|
||||
db.Where("status = ?", models.ArticlePublished).
|
||||
Preload("Author").
|
||||
@@ -71,7 +81,7 @@ func RSSFeed(db *gorm.DB) gin.HandlerFunc {
|
||||
Limit(20).
|
||||
Find(&articles)
|
||||
|
||||
// Build channel metadata.
|
||||
// 构建 channel 元数据。
|
||||
channel := &Channel{
|
||||
Title: siteSetting.LogoText(langStr),
|
||||
Link: baseURL,
|
||||
@@ -80,12 +90,12 @@ func RSSFeed(db *gorm.DB) gin.HandlerFunc {
|
||||
Items: make([]Item, 0, len(articles)),
|
||||
}
|
||||
|
||||
// Set lastBuildDate to the most recent article's publish date.
|
||||
// 将 lastBuildDate 设置为最新文章的发布日期。
|
||||
if len(articles) > 0 && articles[0].PublishedAt != nil {
|
||||
channel.LastBuildDate = formatRSSTime(*articles[0].PublishedAt)
|
||||
}
|
||||
|
||||
// Convert articles to RSS items.
|
||||
// 将文章转换为 RSS 条目。
|
||||
for _, article := range articles {
|
||||
item := Item{
|
||||
Title: article.Title,
|
||||
@@ -95,7 +105,7 @@ func RSSFeed(db *gorm.DB) gin.HandlerFunc {
|
||||
GUID: fmt.Sprintf("%s/article/%s", baseURL, article.Slug),
|
||||
}
|
||||
|
||||
// Add author information.
|
||||
// 添加作者信息。
|
||||
if article.Author.DisplayName != "" {
|
||||
item.Author = article.Author.DisplayName
|
||||
} else {
|
||||
@@ -105,19 +115,19 @@ func RSSFeed(db *gorm.DB) gin.HandlerFunc {
|
||||
channel.Items = append(channel.Items, item)
|
||||
}
|
||||
|
||||
// Build the RSS feed.
|
||||
// 构建 RSS feed。
|
||||
feed := &RSS{
|
||||
Version: "2.0",
|
||||
Channel: channel,
|
||||
}
|
||||
|
||||
// Set the correct content type and return XML.
|
||||
// 设置正确的内容类型并返回 XML。
|
||||
c.Header("Content-Type", "application/rss+xml; charset=utf-8")
|
||||
c.XML(http.StatusOK, feed)
|
||||
}
|
||||
}
|
||||
|
||||
// getRSSLanguage converts the internal language code to RSS language format.
|
||||
// getRSSLanguage 将内部语言代码转换为 RSS 语言格式。
|
||||
func getRSSLanguage(lang string) string {
|
||||
switch lang {
|
||||
case "zh":
|
||||
@@ -129,12 +139,12 @@ func getRSSLanguage(lang string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// formatRSSTime formats a time.Time to RFC1123Z format required by RSS 2.0.
|
||||
// formatRSSTime 将 time.Time 格式化为 RSS 2.0 要求的 RFC1123Z 格式。
|
||||
func formatRSSTime(t time.Time) string {
|
||||
return t.Format(time.RFC1123Z)
|
||||
}
|
||||
|
||||
// getArticlePubDate returns the article's publish date, falling back to created date.
|
||||
// getArticlePubDate 返回文章的发布日期,无则回退到创建日期。
|
||||
func getArticlePubDate(article *models.Article) time.Time {
|
||||
if article.PublishedAt != nil {
|
||||
return *article.PublishedAt
|
||||
@@ -142,14 +152,14 @@ func getArticlePubDate(article *models.Article) time.Time {
|
||||
return article.CreatedAt
|
||||
}
|
||||
|
||||
// getArticleDescription returns the article description for RSS.
|
||||
// Prefers the summary field; falls back to truncated content.
|
||||
// getArticleDescription 返回文章用于 RSS 的描述。
|
||||
// 优先使用摘要字段;没有则回退到截断的正文。
|
||||
func getArticleDescription(article *models.Article) string {
|
||||
if article.Summary != "" {
|
||||
return html.EscapeString(article.Summary)
|
||||
}
|
||||
|
||||
// Strip HTML tags and truncate content to 200 characters.
|
||||
// 去除 HTML 标签并将正文截断到 200 个字符。
|
||||
content := stripHTMLTags(article.Content)
|
||||
if len(content) > 200 {
|
||||
content = content[:200] + "..."
|
||||
@@ -157,9 +167,9 @@ func getArticleDescription(article *models.Article) string {
|
||||
return html.EscapeString(content)
|
||||
}
|
||||
|
||||
// stripHTMLTags removes HTML tags from a string (basic implementation).
|
||||
// stripHTMLTags 从字符串中去除 HTML 标签(基础实现)。
|
||||
func stripHTMLTags(s string) string {
|
||||
// Remove HTML tags by finding < and > pairs.
|
||||
// 通过查找 < 与 > 的配对去除 HTML 标签。
|
||||
var result strings.Builder
|
||||
inTag := false
|
||||
for _, r := range s {
|
||||
@@ -175,7 +185,7 @@ func stripHTMLTags(s string) string {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
// Clean up multiple spaces and trim.
|
||||
// 清理多余空格并去除首尾空白。
|
||||
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
||||
return cleaned
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go_blog/middleware"
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// securityTestEnv 搭建与生产中间件链一致的路由器
|
||||
// (sessions -> CSRF -> 用户上下文),外加待测路由。
|
||||
type securityTestEnv struct {
|
||||
router *gin.Engine
|
||||
db *gorm.DB
|
||||
storageDir string
|
||||
limiter *LoginRateLimiter
|
||||
registerLimiter *WindowRateLimiter
|
||||
commentLimiter *WindowRateLimiter
|
||||
}
|
||||
|
||||
var csrfTokenRe = regexp.MustCompile(`name="_csrf" value="([^"]+)"`)
|
||||
|
||||
func newSecurityTestEnv(t *testing.T) *securityTestEnv {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.User{}, &models.Article{}, &models.Attachment{}, &models.SiteSetting{},
|
||||
&models.UploadConfig{}, &models.UploadFileType{}, &models.CommentConfig{}, &models.NavLink{},
|
||||
&models.DownloadBaseURL{}, &models.Comment{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
storageDir := t.TempDir()
|
||||
|
||||
// 初始化上传策略,使 ValidateUpload 接受 .txt 文件。
|
||||
db.Create(&models.SiteSetting{ID: 1})
|
||||
db.Create(&models.CommentConfig{ID: 1, Enabled: true, AllowGuest: true})
|
||||
db.Create(&models.UploadConfig{ID: 1, Enabled: true, DefaultMaxSize: 1024 * 1024, StorageDir: "attachments"})
|
||||
db.Create(&models.UploadFileType{Extension: ".txt", MimeType: "text/plain", Category: models.CategoryDocument, Enabled: true})
|
||||
models.LoadConfigCache(db)
|
||||
|
||||
// 初始化用户。
|
||||
mustUser(t, db, "admin", models.RoleAdmin)
|
||||
alice := mustUser(t, db, "alice", models.RoleAuthor)
|
||||
bob := mustUser(t, db, "bob", models.RoleAuthor)
|
||||
|
||||
// 每位作者初始化一篇文章。
|
||||
aliceArt := models.Article{AuthorID: alice.ID, Title: "alice post", Slug: "alice-post", Content: "x", Status: models.ArticlePublished}
|
||||
bobArt := models.Article{AuthorID: bob.ID, Title: "bob post", Slug: "bob-post", Content: "x", Status: models.ArticlePublished}
|
||||
db.Create(&aliceArt)
|
||||
db.Create(&bobArt)
|
||||
|
||||
r := gin.New()
|
||||
if err := r.SetTrustedProxies(nil); err != nil {
|
||||
t.Fatalf("set trusted proxies: %v", err)
|
||||
}
|
||||
r.LoadHTMLGlob("../templates/**/*.html")
|
||||
store := cookie.NewStore([]byte("test-secret"))
|
||||
limiter := NewLoginLimiter()
|
||||
registerLimiter := NewWindowLimiter(registerLimitPerHour, registerWindow)
|
||||
commentLimiter := NewWindowLimiter(commentLimitPerMin, commentWindow)
|
||||
r.Use(sessions.Sessions("blog_session", store))
|
||||
r.Use(middleware.SetUserContext(db))
|
||||
r.Use(middleware.BodyLimit())
|
||||
r.Use(middleware.CSRFProtect())
|
||||
|
||||
r.GET("/login", LoginPage())
|
||||
r.GET("/register", RegisterPage(db))
|
||||
r.GET("/rss", RSSFeed(db))
|
||||
|
||||
api := r.Group("/api")
|
||||
{
|
||||
api.POST("/auth/login", Login(db, limiter))
|
||||
api.POST("/auth/logout", Logout())
|
||||
api.POST("/auth/register", Register(db, registerLimiter))
|
||||
api.POST("/article/:slug/comments", PostComment(db, commentLimiter))
|
||||
}
|
||||
|
||||
protected := r.Group("/my", middleware.AuthRequired(db))
|
||||
{
|
||||
protected.GET("/whoami", func(c *gin.Context) {
|
||||
uid, _ := sessionAuthorID(c)
|
||||
c.String(http.StatusOK, "uid=%d", uid)
|
||||
})
|
||||
}
|
||||
|
||||
myAPI := r.Group("/api/my/articles", middleware.AuthRequired(db))
|
||||
{
|
||||
myAPI.POST("", MyArticleCreate(db))
|
||||
myAPI.PUT("/:id", MyArticleUpdate(db))
|
||||
myAPI.DELETE("/:id", MyArticleDelete(db))
|
||||
myAPI.POST("/attachments", UploadAttachment(db, storageDir))
|
||||
myAPI.DELETE("/attachments/:id", DeleteAttachment(db, storageDir))
|
||||
myAPI.GET("/:id/attachments", ListAttachments(db))
|
||||
}
|
||||
|
||||
// 个人资料 API(头像上传 XSS 链回归覆盖,#21)。
|
||||
profileAPI := r.Group("/api/profile", middleware.AuthRequired(db))
|
||||
{
|
||||
profileAPI.POST("", UpdateProfile(db, storageDir))
|
||||
profileAPI.POST("/avatar", UploadAvatar(db, storageDir))
|
||||
}
|
||||
|
||||
// 上传设置 API(危险扩展名黑名单覆盖,#21)。
|
||||
adminSettingsAPI := r.Group("/api/admin/settings", middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
adminSettingsAPI.POST("/upload", UploadSettingsSave(db))
|
||||
// 站点 favicon/logo 上传(#29 魔数校验覆盖)。
|
||||
adminSettingsAPI.POST("/site/favicon", SiteFaviconUpload(db, storageDir))
|
||||
adminSettingsAPI.POST("/site/logo", SiteLogoUpload(db, storageDir))
|
||||
}
|
||||
|
||||
// 后台用户管理路由(SQL 注入回归覆盖,#19)。
|
||||
admin := r.Group("/admin", middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
admin.GET("/users/:id/edit", UserEditPage(db))
|
||||
admin.GET("/comments", CommentListPage(db))
|
||||
}
|
||||
|
||||
usersAPI := r.Group("/api/admin/users", middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
usersAPI.POST("", UserCreate(db))
|
||||
usersAPI.PUT("/:id", UserUpdate(db))
|
||||
usersAPI.DELETE("/:id", UserDelete(db))
|
||||
}
|
||||
|
||||
articleAPI := r.Group("/api/admin/articles", middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
articleAPI.POST("", ArticleCreate(db, "/admin"))
|
||||
articleAPI.PUT("/:id", ArticleUpdate(db, "/admin/articles"))
|
||||
articleAPI.DELETE("/:id", ArticleDelete(db, "/admin/articles"))
|
||||
}
|
||||
|
||||
return &securityTestEnv{router: r, db: db, storageDir: storageDir, limiter: limiter,
|
||||
registerLimiter: registerLimiter, commentLimiter: commentLimiter}
|
||||
}
|
||||
|
||||
func mustUser(t *testing.T, db *gorm.DB, username, role string) models.User {
|
||||
t.Helper()
|
||||
u := models.User{Username: username, DisplayName: username, Role: role, Status: models.StatusNormal}
|
||||
if err := u.SetPassword("pw-" + username); err != nil {
|
||||
t.Fatalf("set password: %v", err)
|
||||
}
|
||||
if err := db.Create(&u).Error; err != nil {
|
||||
t.Fatalf("create user %s: %v", username, err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// login 执行完整登录流程(GET 表单获取 CSRF 令牌,再 POST 凭据),
|
||||
// 返回认证后的会话 Cookie。
|
||||
func (e *securityTestEnv) login(t *testing.T, username string) string {
|
||||
t.Helper()
|
||||
|
||||
// 匿名 GET 获取 CSRF 令牌 + 会话 Cookie。
|
||||
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
w := httptest.NewRecorder()
|
||||
e.router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /login: status = %d", w.Code)
|
||||
}
|
||||
m := csrfTokenRe.FindStringSubmatch(w.Body.String())
|
||||
if m == nil {
|
||||
t.Fatal("login page did not render a CSRF token")
|
||||
}
|
||||
cookie := e.sessionCookie(w)
|
||||
|
||||
// 携带令牌 POST 凭据(JSON API)。
|
||||
w = postJSON(e, http.MethodPost, "/api/auth/login", cookie, m[1], gin.H{
|
||||
"username": username,
|
||||
"password": "pw-" + username,
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("POST /api/auth/login (%s): status = %d, body %s", username, w.Code, w.Body.String())
|
||||
}
|
||||
authCookie := e.sessionCookie(w)
|
||||
if authCookie == "" {
|
||||
t.Fatal("login did not set a session cookie")
|
||||
}
|
||||
return authCookie
|
||||
}
|
||||
|
||||
// sessionCookie 从记录器中提取 blog_session Cookie。当存在多个 Set-Cookie
|
||||
// 头时(例如中间件和处理器都保存了会话),最后一个才是生效值——
|
||||
// 浏览器按顺序应用它们。
|
||||
func (e *securityTestEnv) sessionCookie(w *httptest.ResponseRecorder) string {
|
||||
cookie := ""
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == "blog_session" {
|
||||
cookie = c.Name + "=" + c.Value
|
||||
}
|
||||
}
|
||||
return cookie
|
||||
}
|
||||
|
||||
func (e *securityTestEnv) do(method, path, cookie string, body io.Reader, contentType string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, body)
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
if cookie != "" {
|
||||
req.Header.Set("Cookie", cookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
e.router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// postJSON 是 JSON 请求小助手,以 X-CSRF-Token 请求头发送令牌(AJAX 模式)。
|
||||
func postJSON(e *securityTestEnv, method, path, cookie, csrfToken string, body interface{}) *httptest.ResponseRecorder {
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(body); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if csrfToken != "" {
|
||||
req.Header.Set("X-CSRF-Token", csrfToken)
|
||||
}
|
||||
if cookie != "" {
|
||||
req.Header.Set("Cookie", cookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
e.router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// respCode 从 JSON 错误响应中提取 code 字段。
|
||||
func respCode(w *httptest.ResponseRecorder) string {
|
||||
var r struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &r)
|
||||
return r.Code
|
||||
}
|
||||
|
||||
// respRedirect 从 JSON 成功响应中提取 redirect 字段。
|
||||
func respRedirect(w *httptest.ResponseRecorder) string {
|
||||
var r struct {
|
||||
Redirect string `json:"redirect"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &r)
|
||||
return r.Redirect
|
||||
}
|
||||
|
||||
// respOK 报告 JSON 响应是否成功(ok=true)。
|
||||
func respOK(w *httptest.ResponseRecorder) bool {
|
||||
var r struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &r)
|
||||
return r.OK
|
||||
}
|
||||
|
||||
func (e *securityTestEnv) upload(t *testing.T, cookie, csrfToken, articleID string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var buf strings.Builder
|
||||
mw := multipart.NewWriter(&buf)
|
||||
if articleID != "" {
|
||||
mw.WriteField("article_id", articleID)
|
||||
} else {
|
||||
mw.WriteField("session_token", "test-pending-token")
|
||||
}
|
||||
fw, _ := mw.CreateFormFile("file", "hello.txt")
|
||||
fw.Write([]byte("hello world"))
|
||||
mw.Close()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/my/articles/attachments", strings.NewReader(buf.String()))
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("X-CSRF-Token", csrfToken)
|
||||
if cookie != "" {
|
||||
req.Header.Set("Cookie", cookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
e.router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// deleteAttachment 以 DELETE + CSRF 头删除附件。
|
||||
func (e *securityTestEnv) deleteAttachment(t *testing.T, cookie, csrfToken string, id uint) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return postJSON(e, http.MethodDelete, fmt.Sprintf("/api/my/articles/attachments/%d", id), cookie, csrfToken, nil)
|
||||
}
|
||||
|
||||
// csrfTokenFor 为已认证会话获取一个全新的 CSRF 令牌。
|
||||
func (e *securityTestEnv) csrfTokenFor(t *testing.T, cookie string) string {
|
||||
t.Helper()
|
||||
w := e.do(http.MethodGet, "/login", cookie, nil, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /login: status = %d", w.Code)
|
||||
}
|
||||
return e.csrfTokenFrom(t, w)
|
||||
}
|
||||
|
||||
// anonSession 获取匿名会话 Cookie(GET /login)。
|
||||
func (e *securityTestEnv) anonSession() (string, *httptest.ResponseRecorder) {
|
||||
w := e.do(http.MethodGet, "/login", "", nil, "")
|
||||
return e.sessionCookie(w), w
|
||||
}
|
||||
|
||||
// csrfTokenFrom 从 GET /login 响应体解析 CSRF 令牌。
|
||||
func (e *securityTestEnv) csrfTokenFrom(t *testing.T, w *httptest.ResponseRecorder) string {
|
||||
t.Helper()
|
||||
m := csrfTokenRe.FindStringSubmatch(w.Body.String())
|
||||
if m == nil {
|
||||
t.Fatal("login page did not render a CSRF token")
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
// loginRequest 以 JSON 方式以给定凭据提交登录,返回响应。
|
||||
func (e *securityTestEnv) loginRequest(t *testing.T, username, password string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
anonCookie, w := e.anonSession()
|
||||
token := e.csrfTokenFrom(t, w)
|
||||
return postJSON(e, http.MethodPost, "/api/auth/login", anonCookie, token,
|
||||
gin.H{"username": username, "password": password})
|
||||
}
|
||||
|
||||
// itoa 将 uint 转为十进制字符串(测试辅助)。
|
||||
func itoa(v uint) string {
|
||||
return strconv.FormatUint(uint64(v), 10)
|
||||
}
|
||||
|
||||
func TestLoginRotatesSession(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
|
||||
// 获取匿名会话(登录前的 Cookie)。
|
||||
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
w := httptest.NewRecorder()
|
||||
e.router.ServeHTTP(w, req)
|
||||
preLoginCookie := e.sessionCookie(w)
|
||||
if preLoginCookie == "" {
|
||||
t.Fatal("expected anonymous session cookie")
|
||||
}
|
||||
|
||||
authCookie := e.login(t, "alice")
|
||||
if authCookie == preLoginCookie {
|
||||
t.Fatal("session cookie was not rotated on login (fixation risk)")
|
||||
}
|
||||
|
||||
// 认证会话可正常工作。
|
||||
w = e.do(http.MethodGet, "/my/whoami", authCookie, nil, "")
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "uid=") {
|
||||
t.Fatalf("authenticated request failed: status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 旧的(被固定的)会话不得携带登录状态。
|
||||
w = e.do(http.MethodGet, "/my/whoami", preLoginCookie, nil, "")
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("pre-login session still authenticated after login: status=%d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentListRequiresOwnership(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
var aliceArt, bobArt models.Article
|
||||
e.db.Where("slug = ?", "alice-post").First(&aliceArt)
|
||||
e.db.Where("slug = ?", "bob-post").First(&bobArt)
|
||||
|
||||
alice := e.login(t, "alice")
|
||||
|
||||
// 自己的文章:允许。
|
||||
w := e.do(http.MethodGet, fmt.Sprintf("/api/my/articles/%d/attachments", aliceArt.ID), alice, nil, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list own attachments: status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
// 他人的文章:禁止。
|
||||
w = e.do(http.MethodGet, fmt.Sprintf("/api/my/articles/%d/attachments", bobArt.ID), alice, nil, "")
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("list other user's attachments: status = %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentUploadRequiresOwnership(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
var bobArt models.Article
|
||||
e.db.Where("slug = ?", "bob-post").First(&bobArt)
|
||||
|
||||
alice := e.login(t, "alice")
|
||||
token := e.csrfTokenFor(t, alice)
|
||||
|
||||
// 上传待绑定附件(article_id=0 + 会话令牌):允许。
|
||||
w := e.upload(t, alice, token, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("pending upload: status = %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 上传到他人文章:禁止。
|
||||
w = e.upload(t, alice, token, fmt.Sprint(bobArt.ID))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("upload to other user's article: status = %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentDeleteRequiresOwnership(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
var aliceArt, bobArt models.Article
|
||||
e.db.Where("slug = ?", "alice-post").First(&aliceArt)
|
||||
e.db.Where("slug = ?", "bob-post").First(&bobArt)
|
||||
|
||||
alice := e.login(t, "alice")
|
||||
token := e.csrfTokenFor(t, alice)
|
||||
|
||||
// Alice 上传附件到自己的文章。
|
||||
w := e.upload(t, alice, token, fmt.Sprint(aliceArt.ID))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("upload: status = %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Bob 上传附件到自己的文章。
|
||||
bob := e.login(t, "bob")
|
||||
bobToken := e.csrfTokenFor(t, bob)
|
||||
w = e.upload(t, bob, bobToken, fmt.Sprint(bobArt.ID))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("upload (bob): status = %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var bobAtt models.Attachment
|
||||
if err := e.db.Where("uploader_id = ?", userIDByUsername(t, e.db, "bob")).First(&bobAtt).Error; err != nil {
|
||||
t.Fatalf("bob attachment not found: %v", err)
|
||||
}
|
||||
|
||||
// Alice 不能删除 Bob 的附件。
|
||||
w = e.deleteAttachment(t, alice, token, bobAtt.ID)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("delete other user's attachment: status = %d, want 403", w.Code)
|
||||
}
|
||||
|
||||
// Bob 可以删除自己的附件。
|
||||
w = e.deleteAttachment(t, bob, bobToken, bobAtt.ID)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("delete own attachment: status = %d, want 200 (body %s)", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Bob 的附件记录应该已删除。
|
||||
var count int64
|
||||
e.db.Model(&models.Attachment{}).Where("id = ?", bobAtt.ID).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal("attachment was not deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentCSRFEnforced(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
alice := e.login(t, "alice")
|
||||
|
||||
// 未携带 CSRF 令牌的 POST 必须在到达处理器前被拒绝。
|
||||
var buf strings.Builder
|
||||
mw := multipart.NewWriter(&buf)
|
||||
fw, _ := mw.CreateFormFile("file", "hello.txt")
|
||||
fw.Write([]byte("hello"))
|
||||
mw.Close()
|
||||
w := e.do(http.MethodPost, "/api/my/articles/attachments", alice, strings.NewReader(buf.String()), mw.FormDataContentType())
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("upload without CSRF token: status = %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentAdminOverride(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
var bobArt models.Article
|
||||
e.db.Where("slug = ?", "bob-post").First(&bobArt)
|
||||
|
||||
admin := e.login(t, "admin")
|
||||
token := e.csrfTokenFor(t, admin)
|
||||
|
||||
// 管理员可以列出和上传到任意文章。
|
||||
w := e.do(http.MethodGet, fmt.Sprintf("/api/my/articles/%d/attachments", bobArt.ID), admin, nil, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("admin list: status = %d, want 200", w.Code)
|
||||
}
|
||||
w = e.upload(t, admin, token, fmt.Sprint(bobArt.ID))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("admin upload: status = %d, want 200 (body %s)", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 清理测试期间创建的文件(尽力而为)。
|
||||
entries, _ := os.ReadDir(filepath.Join(e.storageDir, "attachments"))
|
||||
for _, ent := range entries {
|
||||
os.Remove(filepath.Join(e.storageDir, "attachments", ent.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUserRoutesRejectNonNumericIDs(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
admin := e.login(t, "admin")
|
||||
token := e.csrfTokenFor(t, admin)
|
||||
|
||||
var alice models.User
|
||||
if err := e.db.Where("username = ?", "alice").First(&alice).Error; err != nil {
|
||||
t.Fatalf("alice not found: %v", err)
|
||||
}
|
||||
|
||||
// 恶意的 :id 值。在 #19 修复前,GORM 会把非数值的单一字符串条件
|
||||
// 原样插值进 WHERE 子句(例如 WHERE 1 OR 1=1)。
|
||||
ids := []string{
|
||||
"1 OR 1=1",
|
||||
"1;--",
|
||||
"1) OR (1=1",
|
||||
"1 UNION SELECT 1",
|
||||
"alice",
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
// GET 编辑页必须重定向而非渲染匹配到的行。
|
||||
w := e.do(http.MethodGet, "/admin/users/"+url.PathEscape(id)+"/edit", admin, nil, "")
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("GET edit with id %q: status = %d, want 302", id, w.Code)
|
||||
}
|
||||
if loc := w.Header().Get("Location"); loc != "/admin/users" {
|
||||
t.Fatalf("GET edit with id %q: location = %q, want /admin/users", id, loc)
|
||||
}
|
||||
|
||||
// PUT 更新不得修改任何内容(尝试提权)。
|
||||
w = postJSON(e, http.MethodPut, "/api/admin/users/"+url.PathEscape(id), admin, token,
|
||||
gin.H{"role": models.RoleAdmin, "status": 1, "display_name": "hacked"})
|
||||
if w.Code != http.StatusBadRequest || respCode(w) != "api_invalid_request" {
|
||||
t.Fatalf("PUT edit with id %q: status = %d, code = %q", id, w.Code, respCode(w))
|
||||
}
|
||||
|
||||
// DELETE 删除不得删除任何内容。
|
||||
w = postJSON(e, http.MethodDelete, "/api/admin/users/"+url.PathEscape(id), admin, token, nil)
|
||||
if w.Code != http.StatusBadRequest || respCode(w) != "api_invalid_request" {
|
||||
t.Fatalf("DELETE user with id %q: status = %d, code = %q", id, w.Code, respCode(w))
|
||||
}
|
||||
}
|
||||
|
||||
// 任何载荷都不应修改或删除用户。
|
||||
var count int64
|
||||
e.db.Model(&models.User{}).Count(&count)
|
||||
if count != 3 {
|
||||
t.Fatalf("user count = %d, want 3 (injection removed rows)", count)
|
||||
}
|
||||
var check models.User
|
||||
if err := e.db.Where("username = ?", "alice").First(&check).Error; err != nil {
|
||||
t.Fatalf("alice gone: %v", err)
|
||||
}
|
||||
if check.Role != models.RoleAuthor || check.DisplayName != "alice" {
|
||||
t.Fatalf("alice modified via id injection: role=%q display=%q", check.Role, check.DisplayName)
|
||||
}
|
||||
|
||||
// 健全性检查:合法的数值 id 仍然有效。
|
||||
w := e.do(http.MethodGet, fmt.Sprintf("/admin/users/%d/edit", alice.ID), admin, nil, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET edit with valid id: status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func userIDByUsername(t *testing.T, db *gorm.DB, username string) uint {
|
||||
t.Helper()
|
||||
var u models.User
|
||||
if err := db.Where("username = ?", username).First(&u).Error; err != nil {
|
||||
t.Fatalf("user %s not found: %v", username, err)
|
||||
}
|
||||
return u.ID
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// seedUploadType 直接在数据库中插入上传文件类型行并重载配置缓存,
|
||||
// 模拟修复之前创建的策略行。
|
||||
func seedUploadType(t *testing.T, e *securityTestEnv, ext, category string) {
|
||||
t.Helper()
|
||||
if err := e.db.Create(&models.UploadFileType{
|
||||
Extension: ext, MimeType: "application/octet-stream", Category: category, Enabled: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed upload type %s: %v", ext, err)
|
||||
}
|
||||
models.LoadConfigCache(e.db)
|
||||
}
|
||||
|
||||
// pngBytes 生成一张小的合法 PNG。
|
||||
func pngBytes(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, 8, 8))
|
||||
for x := 0; x < 8; x++ {
|
||||
for y := 0; y < 8; y++ {
|
||||
img.Set(x, y, color.RGBA{R: 0x33, G: 0x66, B: 0x99, A: 0xff})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// multipartUpload 提交携带一个文件字段的 multipart 表单。
|
||||
func (e *securityTestEnv) multipartUpload(t *testing.T, path, cookie, csrfToken, fieldName, filename string, content []byte, fields map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
mw.WriteField("_csrf", csrfToken)
|
||||
for k, v := range fields {
|
||||
mw.WriteField(k, v)
|
||||
}
|
||||
fw, _ := mw.CreateFormFile(fieldName, filename)
|
||||
fw.Write(content)
|
||||
mw.Close()
|
||||
return e.do(http.MethodPost, path, cookie, strings.NewReader(buf.String()), mw.FormDataContentType())
|
||||
}
|
||||
|
||||
func reloadAlice(t *testing.T, e *securityTestEnv) models.User {
|
||||
t.Helper()
|
||||
var alice models.User
|
||||
if err := e.db.Where("username = ?", "alice").First(&alice).Error; err != nil {
|
||||
t.Fatalf("load alice: %v", err)
|
||||
}
|
||||
return alice
|
||||
}
|
||||
|
||||
// --- #20:已禁用 / 已锁定 / 已删除用户的过期会话 ---
|
||||
|
||||
func TestDisabledUserSessionInvalidated(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
aliceCookie := e.login(t, "alice")
|
||||
|
||||
// 健全性检查:账户正常时会话有效。
|
||||
if w := e.do(http.MethodGet, "/my/whoami", aliceCookie, nil, ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("pre-disable /my/whoami: status=%d", w.Code)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
status int
|
||||
}{
|
||||
{"disabled", models.StatusDisabled},
|
||||
{"locked", models.StatusLocked},
|
||||
} {
|
||||
e.db.Model(&models.User{}).Where("username = ?", "alice").Update("status", tc.status)
|
||||
w := e.do(http.MethodGet, "/my/whoami", aliceCookie, nil, "")
|
||||
if w.Code != http.StatusFound || w.Header().Get("Location") != "/login" {
|
||||
t.Fatalf("%s user with stale cookie: status=%d location=%q, want 302 /login",
|
||||
tc.name, w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
// 恢复状态,使下一个用例重新从正常账户开始。
|
||||
e.db.Model(&models.User{}).Where("username = ?", "alice").Update("status", models.StatusNormal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftDeletedUserSessionInvalidated(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
aliceCookie := e.login(t, "alice")
|
||||
|
||||
e.db.Where("username = ?", "alice").Delete(&models.User{})
|
||||
w := e.do(http.MethodGet, "/my/whoami", aliceCookie, nil, "")
|
||||
if w.Code != http.StatusFound || w.Header().Get("Location") != "/login" {
|
||||
t.Fatalf("soft-deleted user with stale cookie: status=%d location=%q, want 302 /login",
|
||||
w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledUserCommentsRequireApproval(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
aliceCookie := e.login(t, "alice")
|
||||
token := e.csrfTokenFor(t, aliceCookie)
|
||||
|
||||
// 该场景下访客必须通过审核。
|
||||
e.db.Model(&models.CommentConfig{}).Where("id = ?", 1).Update("guest_require_approval", true)
|
||||
models.LoadConfigCache(e.db)
|
||||
|
||||
postComment := func() models.Comment {
|
||||
t.Helper()
|
||||
w := postJSON(e, http.MethodPost, "/api/article/alice-post/comments", aliceCookie, token,
|
||||
gin.H{
|
||||
"name": "alice",
|
||||
"email": "alice@example.com",
|
||||
"content": "comment body",
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("POST comment: status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var cm models.Comment
|
||||
if err := e.db.Last(&cm).Error; err != nil {
|
||||
t.Fatalf("load comment: %v", err)
|
||||
}
|
||||
return cm
|
||||
}
|
||||
|
||||
// 对照组:alice 为正常用户时,其评论自动通过。
|
||||
if cm := postComment(); cm.Status != models.CommentApproved {
|
||||
t.Fatalf("normal user comment status=%d, want approved", cm.Status)
|
||||
}
|
||||
|
||||
// 被锁定后,她的过期会话不再赋予自动通过权限:
|
||||
// SetUserContext 将她视为未登录,因此评论遵循访客审核策略。
|
||||
e.db.Model(&models.User{}).Where("username = ?", "alice").Update("status", models.StatusLocked)
|
||||
if cm := postComment(); cm.Status != models.CommentPending {
|
||||
t.Fatalf("locked user comment status=%d, want pending", cm.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// --- #21:头像上传 XSS 链 ---
|
||||
|
||||
func TestAddUploadFileTypeRejectsDangerousExtensions(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
admin := e.login(t, "admin")
|
||||
token := e.csrfTokenFor(t, admin)
|
||||
|
||||
// SECURITY_TODO #32:黑名单补充 .xsl/.xslt/.shtml 后并入同一用例。
|
||||
for _, ext := range []string{"html", ".htm", "SVG", "xhtml", ".xml", "js", ".xsl", ".xslt", ".shtml"} {
|
||||
w := postJSON(e, http.MethodPost, "/api/admin/settings/upload", admin, token,
|
||||
gin.H{"action": "add_type", "extension": ext, "category": models.CategoryImage})
|
||||
if w.Code != http.StatusBadRequest || respCode(w) != "settings_upload_dangerous_ext" {
|
||||
t.Fatalf("add type %q: status=%d code=%q, want 400/settings_upload_dangerous_ext",
|
||||
ext, w.Code, respCode(w))
|
||||
}
|
||||
var count int64
|
||||
normalized := strings.ToLower(ext)
|
||||
if !strings.HasPrefix(normalized, ".") {
|
||||
normalized = "." + normalized
|
||||
}
|
||||
e.db.Model(&models.UploadFileType{}).Where("extension = ?", normalized).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatalf("dangerous extension %q was persisted", ext)
|
||||
}
|
||||
}
|
||||
|
||||
// 对照组:良性的扩展名仍然被接受。
|
||||
w := postJSON(e, http.MethodPost, "/api/admin/settings/upload", admin, token,
|
||||
gin.H{"action": "add_type", "extension": "md", "category": models.CategoryDocument})
|
||||
if w.Code != http.StatusOK || !respOK(w) {
|
||||
t.Fatalf("add benign type: status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var count int64
|
||||
e.db.Model(&models.UploadFileType{}).Where("extension = ?", ".md").Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatalf("benign extension .md not created (count=%d)", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAvatarRejectsNonImage(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
aliceCookie := e.login(t, "alice")
|
||||
token := e.csrfTokenFor(t, aliceCookie)
|
||||
|
||||
htmlPayload := []byte("<html><script>alert(document.cookie)</script></html>")
|
||||
|
||||
// 模拟黑名单之前已配置的危险类型(纵深防御):类别检查必须拒绝它。
|
||||
seedUploadType(t, e, ".html", models.CategoryOther)
|
||||
w := e.multipartUpload(t, "/api/profile/avatar", aliceCookie, token, "avatar", "evil.html", htmlPayload, nil)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("upload .html (other category): status=%d body=%s, want 400", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 即使是误分类为 "image" 的旧版 .html 行,也会被解码步骤拦下——
|
||||
// 不会再存储原始字节。
|
||||
seedUploadType(t, e, ".htm", models.CategoryImage)
|
||||
w = e.multipartUpload(t, "/api/profile/avatar", aliceCookie, token, "avatar", "evil.htm", htmlPayload, nil)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("upload .htm (image category): status=%d body=%s, want 400", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 伪装在白名单图片扩展名之后的 HTML 同样被拒绝。
|
||||
seedUploadType(t, e, ".jpg", models.CategoryImage)
|
||||
w = e.multipartUpload(t, "/api/profile/avatar", aliceCookie, token, "avatar", "x.jpg", htmlPayload, nil)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("upload html as .jpg: status=%d body=%s, want 400", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 未存储任何内容,头像保持不变。
|
||||
if alice := reloadAlice(t, e); alice.Avatar != "" {
|
||||
t.Fatalf("avatar unexpectedly set to %q", alice.Avatar)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(e.storageDir, "avatars")); !os.IsNotExist(err) {
|
||||
entries, _ := os.ReadDir(filepath.Join(e.storageDir, "avatars"))
|
||||
for _, en := range entries {
|
||||
t.Logf("avatars dir entry: %s", en.Name())
|
||||
}
|
||||
t.Fatal("avatar directory should not contain any file after rejected uploads")
|
||||
}
|
||||
|
||||
// 真实图片被接受,并处理为规范化 JPEG。
|
||||
seedUploadType(t, e, ".png", models.CategoryImage)
|
||||
w = e.multipartUpload(t, "/api/profile/avatar", aliceCookie, token, "avatar", "me.png", pngBytes(t), nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("upload valid png: status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
alice := reloadAlice(t, e)
|
||||
if want := fmt.Sprintf("%d.jpg", alice.ID); alice.Avatar != want {
|
||||
t.Fatalf("avatar = %q, want %q", alice.Avatar, want)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(e.storageDir, "avatars", alice.Avatar)); err != nil {
|
||||
t.Fatalf("processed avatar file missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfileAvatarRejectsNonImage(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
aliceCookie := e.login(t, "alice")
|
||||
token := e.csrfTokenFor(t, aliceCookie)
|
||||
|
||||
seedUploadType(t, e, ".png", models.CategoryImage)
|
||||
seedUploadType(t, e, ".html", models.CategoryImage) // 旧版误分类的行
|
||||
|
||||
// 白名单扩展名背后的 HTML 必须被 JSON API 拒绝,
|
||||
// 且不得向 avatars/ 写入任何内容。
|
||||
htmlPayload := []byte("<html><script>alert(1)</script></html>")
|
||||
w := e.multipartUpload(t, "/api/profile/avatar", aliceCookie, token, "avatar", "evil.html", htmlPayload, nil)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("upload html avatar: status=%d body=%s, want 400", w.Code, w.Body.String())
|
||||
}
|
||||
if alice := reloadAlice(t, e); alice.Avatar != "" {
|
||||
t.Fatalf("avatar unexpectedly set to %q", alice.Avatar)
|
||||
}
|
||||
|
||||
// 真实图片经过处理并以 JPEG 保存。
|
||||
w = e.multipartUpload(t, "/api/profile/avatar", aliceCookie, token, "avatar", "me.png", pngBytes(t), nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("upload valid avatar: status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
alice := reloadAlice(t, e)
|
||||
if want := fmt.Sprintf("%d.jpg", alice.ID); alice.Avatar != want {
|
||||
t.Fatalf("avatar = %q, want %q", alice.Avatar, want)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(e.storageDir, "avatars", alice.Avatar)); err != nil {
|
||||
t.Fatalf("processed avatar file missing: %v", err)
|
||||
}
|
||||
}
|
||||
+401
-211
@@ -16,9 +16,8 @@ import (
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// mbToBytes converts a megabyte count (string) to bytes. Returns 0 on parse
|
||||
// failure. Values <= 0 are treated as 0 (meaning "use default" for per-type
|
||||
// limits).
|
||||
// mbToBytes 将兆字节数(字符串)转换为字节。解析失败时返回 0。
|
||||
// <= 0 的值视为 0(对按类型限制来说表示"使用默认值")。
|
||||
func mbToBytes(s string) int64 {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
@@ -31,13 +30,13 @@ func mbToBytes(s string) int64 {
|
||||
return int64(n * 1024 * 1024)
|
||||
}
|
||||
|
||||
// bytesToMB renders a byte count as megabytes (one decimal) for form display.
|
||||
// bytesToMB 将字节数渲染为兆字节(一位小数)用于表单展示。
|
||||
func bytesToMB(b int64) string {
|
||||
return fmt.Sprintf("%.1f", float64(b)/float64(1024*1024))
|
||||
}
|
||||
|
||||
// userIDFromSession extracts the logged-in user's ID, or 0 if absent. It
|
||||
// defends against int/uint/int64/float64 storage in the session.
|
||||
// userIDFromSession 提取已登录用户的 ID,不存在时为 0。
|
||||
// 它兼容会话中 int/uint/int64/float64 的存储类型。
|
||||
func userIDFromSession(c *gin.Context) uint {
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
@@ -57,9 +56,38 @@ func userIDFromSession(c *gin.Context) uint {
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---------------- Site settings ----------------
|
||||
// mbFromFloat 将兆字节数(浮点,来自 JSON)转换为字节。
|
||||
func mbFromFloat(v float64) int64 {
|
||||
if v <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int64(v * 1024 * 1024)
|
||||
}
|
||||
|
||||
// SiteSettingsPage renders the site display settings form.
|
||||
// siteSettingsRequest 是 POST /api/admin/settings/site 的 JSON 请求体。
|
||||
// 文本字段为空即存储为空;favicon/logo 的清除与 URL 设置通过专用字段。
|
||||
type siteSettingsRequest struct {
|
||||
LogoTextZh string `json:"logo_text_zh"`
|
||||
LogoTextEn string `json:"logo_text_en"`
|
||||
HeaderTextZh string `json:"header_text_zh"`
|
||||
HeaderTextEn string `json:"header_text_en"`
|
||||
HomeWelcomeZh string `json:"home_welcome_zh"`
|
||||
HomeWelcomeEn string `json:"home_welcome_en"`
|
||||
HomeSubtitleZh string `json:"home_subtitle_zh"`
|
||||
HomeSubtitleEn string `json:"home_subtitle_en"`
|
||||
FooterTextZh string `json:"footer_text_zh"`
|
||||
FooterTextEn string `json:"footer_text_en"`
|
||||
SiteURL string `json:"site_url"`
|
||||
AllowRegistration bool `json:"allow_registration"`
|
||||
FaviconURL string `json:"favicon_url"`
|
||||
FaviconClear bool `json:"favicon_clear"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
LogoClear bool `json:"logo_clear"`
|
||||
}
|
||||
|
||||
// ---------------- 站点设置 ----------------
|
||||
|
||||
// SiteSettingsPage 渲染站点显示设置表单。
|
||||
func SiteSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
@@ -71,8 +99,8 @@ func SiteSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
data := DefaultData(c)
|
||||
data["Title"] = tr["settings_site_title"]
|
||||
data["Site"] = s
|
||||
// Pre-compute derived values so the template never invokes methods on
|
||||
// an interface{}-wrapped struct (which Go templates cannot resolve).
|
||||
// 预计算派生值,使模板绝不调用包装为 interface{} 的结构体上的方法
|
||||
//(Go 模板无法解析此类调用)。
|
||||
data["SiteLogoIsURL"] = s.LogoIsURL()
|
||||
data["SiteFaviconIsURL"] = s.FaviconIsURL()
|
||||
if msg := c.Query("saved"); msg == "1" {
|
||||
@@ -82,126 +110,162 @@ func SiteSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// SiteSettingsSave handles logo upload and text fields for site settings.
|
||||
// SiteSettingsSave 处理站点设置的文本字段。
|
||||
// favicon/logo 文件上传走 SiteFaviconUpload / SiteLogoUpload(multipart)。
|
||||
func SiteSettingsSave(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req siteSettingsRequest
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
var s models.SiteSetting
|
||||
if err := db.First(&s, 1).Error; err != nil {
|
||||
s = models.SiteSetting{ID: 1}
|
||||
}
|
||||
|
||||
s.LogoTextZh = strings.TrimSpace(req.LogoTextZh)
|
||||
s.LogoTextEn = strings.TrimSpace(req.LogoTextEn)
|
||||
s.HeaderTextZh = strings.TrimSpace(req.HeaderTextZh)
|
||||
s.HeaderTextEn = strings.TrimSpace(req.HeaderTextEn)
|
||||
s.HomeWelcomeZh = strings.TrimSpace(req.HomeWelcomeZh)
|
||||
s.HomeWelcomeEn = strings.TrimSpace(req.HomeWelcomeEn)
|
||||
s.HomeSubtitleZh = strings.TrimSpace(req.HomeSubtitleZh)
|
||||
s.HomeSubtitleEn = strings.TrimSpace(req.HomeSubtitleEn)
|
||||
s.FooterTextZh = strings.TrimSpace(req.FooterTextZh)
|
||||
s.FooterTextEn = strings.TrimSpace(req.FooterTextEn)
|
||||
// SECURITY_TODO #16:规范化的 feed/站点 URL;RSS 使用它而非请求的
|
||||
// Host,以避免 Host 头污染。
|
||||
s.SiteURL = strings.TrimSpace(req.SiteURL)
|
||||
s.AllowRegistration = req.AllowRegistration
|
||||
s.UpdatedBy = userIDFromSession(c)
|
||||
|
||||
// favicon:URL 设置优先;clear 标志移除本地文件。
|
||||
if req.FaviconClear {
|
||||
if s.Favicon != "" && !s.FaviconIsURL() {
|
||||
os.Remove(filepath.Join(storagePath, "logos", s.Favicon))
|
||||
}
|
||||
s.Favicon = ""
|
||||
} else if faviconURL := strings.TrimSpace(req.FaviconURL); faviconURL != "" {
|
||||
s.Favicon = faviconURL
|
||||
}
|
||||
|
||||
// logo:URL 设置优先;clear 标志移除本地文件。
|
||||
if req.LogoClear {
|
||||
if s.Logo != "" && !s.LogoIsURL() {
|
||||
os.Remove(filepath.Join(storagePath, "logos", s.Logo))
|
||||
}
|
||||
s.Logo = ""
|
||||
} else if logoURL := strings.TrimSpace(req.LogoURL); logoURL != "" {
|
||||
s.Logo = logoURL
|
||||
}
|
||||
|
||||
if err := db.Save(&s).Error; err != nil {
|
||||
APIError(c, http.StatusInternalServerError, "api_error")
|
||||
return
|
||||
}
|
||||
models.RefreshConfigCache(db)
|
||||
APIOK(c, "/admin/settings/site?saved=1", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// SiteFaviconUpload 上传 favicon 图片(multipart,字段名 favicon)。
|
||||
// 校验类别为图片后存储到 logos/,并替换旧本地文件。
|
||||
func SiteFaviconUpload(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
return saveSiteImage(db, storagePath, "favicon", "favicon")
|
||||
}
|
||||
|
||||
// SiteLogoUpload 上传站点 logo 图片(multipart,字段名 logo)。
|
||||
func SiteLogoUpload(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
return saveSiteImage(db, storagePath, "logo", "logo")
|
||||
}
|
||||
|
||||
// saveSiteImage 保存站点 favicon/logo 的公共实现。
|
||||
// fieldName 是 multipart 字段名;prefix 是存储文件名前缀。
|
||||
func saveSiteImage(db *gorm.DB, storagePath, fieldName, prefix string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var s models.SiteSetting
|
||||
if err := db.First(&s, 1).Error; err != nil {
|
||||
s = models.SiteSetting{ID: 1}
|
||||
}
|
||||
|
||||
s.LogoTextZh = strings.TrimSpace(c.PostForm("logo_text_zh"))
|
||||
s.LogoTextEn = strings.TrimSpace(c.PostForm("logo_text_en"))
|
||||
s.HeaderTextZh = strings.TrimSpace(c.PostForm("header_text_zh"))
|
||||
s.HeaderTextEn = strings.TrimSpace(c.PostForm("header_text_en"))
|
||||
s.HomeWelcomeZh = strings.TrimSpace(c.PostForm("home_welcome_zh"))
|
||||
s.HomeWelcomeEn = strings.TrimSpace(c.PostForm("home_welcome_en"))
|
||||
s.HomeSubtitleZh = strings.TrimSpace(c.PostForm("home_subtitle_zh"))
|
||||
s.HomeSubtitleEn = strings.TrimSpace(c.PostForm("home_subtitle_en"))
|
||||
s.FooterTextZh = strings.TrimSpace(c.PostForm("footer_text_zh"))
|
||||
s.FooterTextEn = strings.TrimSpace(c.PostForm("footer_text_en"))
|
||||
s.AllowRegistration = c.PostForm("allow_registration") == "1"
|
||||
s.UpdatedBy = userIDFromSession(c)
|
||||
file, header, err := c.Request.FormFile(fieldName)
|
||||
if err != nil {
|
||||
APIError(c, http.StatusBadRequest, "api_invalid_request")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
check := ValidateUpload(header)
|
||||
if !check.OK || check.Type.Category != models.CategoryImage {
|
||||
APIError(c, http.StatusBadRequest, "settings_upload_dangerous_ext")
|
||||
return
|
||||
}
|
||||
|
||||
// Favicon upload (optional). A favicon_url form field takes precedence over an
|
||||
// uploaded file, so admins can set either a local file or an external link.
|
||||
if faviconURL := strings.TrimSpace(c.PostForm("favicon_url")); faviconURL != "" {
|
||||
s.Favicon = faviconURL
|
||||
} else if file, header, err := c.Request.FormFile("favicon"); err == nil {
|
||||
defer file.Close()
|
||||
check := ValidateUpload(header)
|
||||
if !check.OK || check.Type.Category != models.CategoryImage {
|
||||
c.Redirect(http.StatusFound, "/admin/settings/site")
|
||||
return
|
||||
}
|
||||
logoDir := filepath.Join(storagePath, "logos")
|
||||
os.MkdirAll(logoDir, 0755)
|
||||
// Remove the previous local favicon (skip external URLs).
|
||||
if s.Favicon != "" && !s.FaviconIsURL() {
|
||||
os.Remove(filepath.Join(logoDir, s.Favicon))
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
savedName := fmt.Sprintf("favicon%s", ext)
|
||||
dst, err := os.Create(filepath.Join(logoDir, savedName))
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/settings/site")
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/settings/site")
|
||||
return
|
||||
}
|
||||
// SECURITY_TODO #29:与头像/附件上传一致,做魔数一致性校验——
|
||||
// 管理员不得把 HTML 字节另存为 .png 等图片扩展名。当前靠
|
||||
// X-Content-Type-Options: nosniff + 按扩展名的 Content-Type 兜底,
|
||||
// 纵深防御链条在此补齐。
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
APIError(c, http.StatusInternalServerError, "api_error")
|
||||
return
|
||||
}
|
||||
if !contentMatchesType(check.Type, content) {
|
||||
APIError(c, http.StatusBadRequest, "settings_upload_bad_content")
|
||||
return
|
||||
}
|
||||
|
||||
logoDir := filepath.Join(storagePath, "logos")
|
||||
os.MkdirAll(logoDir, 0755)
|
||||
// 删除之前的本地文件(跳过外部 URL)。
|
||||
existing := s.Favicon
|
||||
if prefix == "logo" {
|
||||
existing = s.Logo
|
||||
}
|
||||
if existing != "" && (prefix == "logo" && !s.LogoIsURL() || prefix == "favicon" && !s.FaviconIsURL()) {
|
||||
os.Remove(filepath.Join(logoDir, existing))
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
savedName := fmt.Sprintf("%s%s", prefix, ext)
|
||||
dst, err := os.Create(filepath.Join(logoDir, savedName))
|
||||
if err != nil {
|
||||
APIError(c, http.StatusInternalServerError, "api_error")
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
if _, err := dst.Write(content); err != nil {
|
||||
APIError(c, http.StatusInternalServerError, "api_error")
|
||||
return
|
||||
}
|
||||
|
||||
if prefix == "favicon" {
|
||||
s.Favicon = savedName
|
||||
}
|
||||
|
||||
// Remove favicon entirely if requested.
|
||||
if c.PostForm("favicon_clear") == "1" {
|
||||
if s.Favicon != "" && !s.FaviconIsURL() {
|
||||
os.Remove(filepath.Join(storagePath, "logos", s.Favicon))
|
||||
}
|
||||
s.Favicon = ""
|
||||
}
|
||||
|
||||
// Logo upload (optional). A logo_url form field takes precedence over an
|
||||
// uploaded file, so admins can set either a local file or an external link.
|
||||
if logoURL := strings.TrimSpace(c.PostForm("logo_url")); logoURL != "" {
|
||||
s.Logo = logoURL
|
||||
} else if file, header, err := c.Request.FormFile("logo"); err == nil {
|
||||
defer file.Close()
|
||||
check := ValidateUpload(header)
|
||||
if !check.OK || check.Type.Category != models.CategoryImage {
|
||||
c.Redirect(http.StatusFound, "/admin/settings/site")
|
||||
return
|
||||
}
|
||||
logoDir := filepath.Join(storagePath, "logos")
|
||||
os.MkdirAll(logoDir, 0755)
|
||||
// Remove the previous local logo (skip external URLs).
|
||||
if s.Logo != "" && !s.LogoIsURL() {
|
||||
os.Remove(filepath.Join(logoDir, s.Logo))
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
savedName := fmt.Sprintf("logo%s", ext)
|
||||
dst, err := os.Create(filepath.Join(logoDir, savedName))
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/settings/site")
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/settings/site")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
s.Logo = savedName
|
||||
}
|
||||
|
||||
// Remove logo entirely if requested.
|
||||
if c.PostForm("logo_clear") == "1" {
|
||||
if s.Logo != "" && !s.LogoIsURL() {
|
||||
os.Remove(filepath.Join(storagePath, "logos", s.Logo))
|
||||
}
|
||||
s.Logo = ""
|
||||
}
|
||||
|
||||
if err := db.Save(&s).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/settings/site")
|
||||
APIError(c, http.StatusInternalServerError, "api_error")
|
||||
return
|
||||
}
|
||||
models.RefreshConfigCache(db)
|
||||
c.Redirect(http.StatusFound, "/admin/settings/site?saved=1")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"name": savedName,
|
||||
"redirect": "/admin/settings/site?saved=1",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Upload settings ----------------
|
||||
// ---------------- 上传设置 ----------------
|
||||
|
||||
// fileTypeView augments an UploadFileType with a pre-rendered max-size MB
|
||||
// string for the template (avoids needing a template FuncMap for division).
|
||||
// fileTypeView 为 UploadFileType 附加预渲染的最大大小 MB 字符串供模板使用
|
||||
// (避免为除法引入模板 FuncMap)。
|
||||
type fileTypeView struct {
|
||||
models.UploadFileType
|
||||
MaxSizeMB string
|
||||
}
|
||||
|
||||
// UploadSettingsPage renders the upload policy + file-type management page.
|
||||
// UploadSettingsPage 渲染上传策略 + 文件类型管理页面。
|
||||
func UploadSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
@@ -225,98 +289,177 @@ func UploadSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
if msg := c.Query("saved"); msg == "1" {
|
||||
data["Success"] = tr["settings_saved"]
|
||||
}
|
||||
if msg := c.Query("error"); msg == "dangerous_ext" {
|
||||
data["Error"] = tr["settings_upload_dangerous_ext"]
|
||||
}
|
||||
if c.Query("error") == "illegal_dir" {
|
||||
data["Error"] = tr["settings_upload_illegal_dir"]
|
||||
}
|
||||
c.HTML(http.StatusOK, "settings_upload", data)
|
||||
}
|
||||
}
|
||||
|
||||
// UploadSettingsSave dispatches upload-config and file-type actions.
|
||||
// uploadSettingsRequest 是 POST /api/admin/settings/upload 的 JSON 请求体。
|
||||
// action 分发:save_config / add_type / toggle_type / size_type / delete_type。
|
||||
// Enabled 为 nil 表示未提交(add_type 默认启用)。
|
||||
type uploadSettingsRequest struct {
|
||||
Action string `json:"action"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
DefaultMaxSize float64 `json:"default_max_size"` // MB
|
||||
StorageDir string `json:"storage_dir"`
|
||||
ID int `json:"id"`
|
||||
Extension string `json:"extension"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Category string `json:"category"`
|
||||
MaxSize float64 `json:"max_size"` // MB
|
||||
}
|
||||
|
||||
// UploadSettingsSave 分发上传配置与文件类型操作。
|
||||
func UploadSettingsSave(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
switch c.PostForm("action") {
|
||||
var req uploadSettingsRequest
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
switch req.Action {
|
||||
case "save_config":
|
||||
saveUploadConfig(db, c)
|
||||
if !saveUploadConfig(db, req, userIDFromSession(c)) {
|
||||
// SECURITY (#22):非法的 storage_dir 已被拒绝;
|
||||
// 保留原值并报告错误。
|
||||
APIError(c, http.StatusBadRequest, "settings_upload_illegal_dir")
|
||||
return
|
||||
}
|
||||
case "add_type":
|
||||
addUploadFileType(db, c)
|
||||
if addUploadFileType(db, req) {
|
||||
APIError(c, http.StatusBadRequest, "settings_upload_dangerous_ext")
|
||||
return
|
||||
}
|
||||
case "toggle_type":
|
||||
toggleUploadFileType(db, c)
|
||||
toggleUploadFileType(db, req)
|
||||
case "size_type":
|
||||
sizeUploadFileType(db, c)
|
||||
sizeUploadFileType(db, req)
|
||||
case "delete_type":
|
||||
deleteUploadFileType(db, c)
|
||||
deleteUploadFileType(db, req)
|
||||
default:
|
||||
APIError(c, http.StatusBadRequest, "api_invalid_request")
|
||||
return
|
||||
}
|
||||
models.RefreshConfigCache(db)
|
||||
c.Redirect(http.StatusFound, "/admin/settings/upload?saved=1")
|
||||
APIOK(c, "/admin/settings/upload?saved=1", nil)
|
||||
}
|
||||
}
|
||||
|
||||
func saveUploadConfig(db *gorm.DB, c *gin.Context) {
|
||||
// safeStorageDirName 报告 s 是否为单一安全路径段:无分隔符、
|
||||
// 无路径穿越、非绝对路径。storage_dir 必须保持在存储根目录内
|
||||
// (SECURITY_TODO #22)。
|
||||
func safeStorageDirName(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// saveUploadConfig 持久化上传策略。当提交的 storage_dir 不安全时
|
||||
// (SECURITY_TODO #22),返回 false 且不修改存储的值,
|
||||
// 以免配置错误的管理员将附件写入存储根目录之外。
|
||||
func saveUploadConfig(db *gorm.DB, req uploadSettingsRequest, updatedBy uint) bool {
|
||||
var u models.UploadConfig
|
||||
if err := db.First(&u, 1).Error; err != nil {
|
||||
u = models.UploadConfig{ID: 1}
|
||||
}
|
||||
u.Enabled = c.PostForm("enabled") == "1"
|
||||
u.DefaultMaxSize = mbToBytes(c.PostForm("default_max_size"))
|
||||
if req.Enabled != nil {
|
||||
u.Enabled = *req.Enabled
|
||||
}
|
||||
u.DefaultMaxSize = mbFromFloat(req.DefaultMaxSize)
|
||||
if u.DefaultMaxSize <= 0 {
|
||||
u.DefaultMaxSize = models.DefaultUploadMaxSize
|
||||
}
|
||||
if dir := strings.TrimSpace(c.PostForm("storage_dir")); dir != "" {
|
||||
if dir := strings.TrimSpace(req.StorageDir); dir != "" {
|
||||
if !safeStorageDirName(dir) {
|
||||
return false
|
||||
}
|
||||
u.StorageDir = dir
|
||||
}
|
||||
u.UpdatedBy = userIDFromSession(c)
|
||||
u.UpdatedBy = updatedBy
|
||||
db.Save(&u)
|
||||
return true
|
||||
}
|
||||
|
||||
func addUploadFileType(db *gorm.DB, c *gin.Context) {
|
||||
ext := strings.ToLower(strings.TrimSpace(c.PostForm("extension")))
|
||||
// dangerousUploadExtensions 永不接受为上传文件类型:这些扩展名的文件将从
|
||||
// /uploads 同源提供,可在站点源上执行活动内容(HTML/SVG/JS),
|
||||
// 使任何已登录的上传者获得存储型 XSS 能力(SECURITY_TODO #21)。
|
||||
var dangerousUploadExtensions = map[string]bool{
|
||||
".html": true, ".htm": true, ".xhtml": true, ".xht": true,
|
||||
".svg": true, ".xml": true, ".js": true, ".mjs": true,
|
||||
// SECURITY_TODO #32:补充服务器端处理型扩展名(XSLT 可内嵌脚本、
|
||||
// SSI 可包含文件),nosniff 已兜底,此处仅完整性。
|
||||
".xsl": true, ".xslt": true, ".shtml": true,
|
||||
}
|
||||
|
||||
// addUploadFileType 创建新的允许文件类型。报告扩展名是否因危险而被拒绝。
|
||||
func addUploadFileType(db *gorm.DB, req uploadSettingsRequest) bool {
|
||||
ext := strings.ToLower(strings.TrimSpace(req.Extension))
|
||||
if ext == "" {
|
||||
return
|
||||
return false
|
||||
}
|
||||
if !strings.HasPrefix(ext, ".") {
|
||||
ext = "." + ext
|
||||
}
|
||||
if dangerousUploadExtensions[ext] {
|
||||
return true
|
||||
}
|
||||
enabled := true // 未提交 Enabled 时默认启用(与旧表单语义一致)
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
t := models.UploadFileType{
|
||||
Extension: ext,
|
||||
MimeType: strings.TrimSpace(c.PostForm("mime_type")),
|
||||
Category: strings.TrimSpace(c.PostForm("category")),
|
||||
MaxSize: mbToBytes(c.PostForm("max_size")),
|
||||
Enabled: c.PostForm("enabled") != "0",
|
||||
MimeType: strings.TrimSpace(req.MimeType),
|
||||
Category: strings.TrimSpace(req.Category),
|
||||
MaxSize: mbFromFloat(req.MaxSize),
|
||||
Enabled: enabled,
|
||||
Sort: 50,
|
||||
}
|
||||
if t.Category == "" {
|
||||
t.Category = models.CategoryOther
|
||||
}
|
||||
// Ignore duplicate-extension errors silently.
|
||||
// 静默忽略扩展名重复的错误。
|
||||
db.Where("extension = ?", t.Extension).FirstOrCreate(&t)
|
||||
return false
|
||||
}
|
||||
|
||||
func toggleUploadFileType(db *gorm.DB, c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||
func toggleUploadFileType(db *gorm.DB, req uploadSettingsRequest) {
|
||||
var t models.UploadFileType
|
||||
if db.First(&t, id).Error != nil {
|
||||
if db.First(&t, req.ID).Error != nil {
|
||||
return
|
||||
}
|
||||
t.Enabled = !t.Enabled
|
||||
db.Save(&t)
|
||||
}
|
||||
|
||||
func sizeUploadFileType(db *gorm.DB, c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||
func sizeUploadFileType(db *gorm.DB, req uploadSettingsRequest) {
|
||||
var t models.UploadFileType
|
||||
if db.First(&t, id).Error != nil {
|
||||
if db.First(&t, req.ID).Error != nil {
|
||||
return
|
||||
}
|
||||
t.MaxSize = mbToBytes(c.PostForm("max_size"))
|
||||
t.MaxSize = mbFromFloat(req.MaxSize)
|
||||
db.Save(&t)
|
||||
}
|
||||
|
||||
func deleteUploadFileType(db *gorm.DB, c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||
db.Delete(&models.UploadFileType{}, id)
|
||||
func deleteUploadFileType(db *gorm.DB, req uploadSettingsRequest) {
|
||||
db.Delete(&models.UploadFileType{}, req.ID)
|
||||
}
|
||||
|
||||
// ---------------- Download settings ----------------
|
||||
// ---------------- 下载设置 ----------------
|
||||
|
||||
// DownloadSettingsPage renders the download base-URL management page.
|
||||
// DownloadSettingsPage 渲染下载基础 URL 管理页面。
|
||||
func DownloadSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
@@ -333,65 +476,82 @@ func DownloadSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadSettingsSave dispatches download base-URL actions.
|
||||
// downloadSettingsRequest 是 POST /api/admin/settings/download 的 JSON 请求体。
|
||||
// action 分发:add / toggle / default / delete。
|
||||
type downloadSettingsRequest struct {
|
||||
Action string `json:"action"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Priority int `json:"priority"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
// DownloadSettingsSave 分发下载基础 URL 操作。
|
||||
func DownloadSettingsSave(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
switch c.PostForm("action") {
|
||||
var req downloadSettingsRequest
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
switch req.Action {
|
||||
case "add":
|
||||
addDownloadBaseURL(db, c)
|
||||
addDownloadBaseURL(db, req)
|
||||
case "toggle":
|
||||
toggleDownloadBaseURL(db, c)
|
||||
toggleDownloadBaseURL(db, req)
|
||||
case "default":
|
||||
defaultDownloadBaseURL(db, c)
|
||||
defaultDownloadBaseURL(db, req)
|
||||
case "delete":
|
||||
deleteDownloadBaseURL(db, c)
|
||||
deleteDownloadBaseURL(db, req)
|
||||
default:
|
||||
APIError(c, http.StatusBadRequest, "api_invalid_request")
|
||||
return
|
||||
}
|
||||
models.RefreshConfigCache(db)
|
||||
c.Redirect(http.StatusFound, "/admin/settings/download?saved=1")
|
||||
APIOK(c, "/admin/settings/download?saved=1", nil)
|
||||
}
|
||||
}
|
||||
|
||||
func addDownloadBaseURL(db *gorm.DB, c *gin.Context) {
|
||||
name := strings.TrimSpace(c.PostForm("name"))
|
||||
base := strings.TrimSpace(c.PostForm("base_url"))
|
||||
func addDownloadBaseURL(db *gorm.DB, req downloadSettingsRequest) {
|
||||
base := strings.TrimSpace(req.BaseURL)
|
||||
if base == "" {
|
||||
return
|
||||
}
|
||||
prio, _ := strconv.Atoi(c.PostForm("priority"))
|
||||
enabled := true // 未提交 Enabled 时默认启用
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
b := models.DownloadBaseURL{
|
||||
Name: name,
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
BaseURL: base,
|
||||
Priority: prio,
|
||||
Enabled: c.PostForm("enabled") != "0",
|
||||
Priority: req.Priority,
|
||||
Enabled: enabled,
|
||||
}
|
||||
db.Create(&b)
|
||||
}
|
||||
|
||||
func toggleDownloadBaseURL(db *gorm.DB, c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||
func toggleDownloadBaseURL(db *gorm.DB, req downloadSettingsRequest) {
|
||||
var b models.DownloadBaseURL
|
||||
if db.First(&b, id).Error != nil {
|
||||
if db.First(&b, req.ID).Error != nil {
|
||||
return
|
||||
}
|
||||
b.Enabled = !b.Enabled
|
||||
db.Save(&b)
|
||||
}
|
||||
|
||||
func defaultDownloadBaseURL(db *gorm.DB, c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||
// Only one default at a time.
|
||||
func defaultDownloadBaseURL(db *gorm.DB, req downloadSettingsRequest) {
|
||||
// 同一时间只能有一个默认项。
|
||||
db.Model(&models.DownloadBaseURL{}).Where("1=1").Update("is_default", false)
|
||||
db.Model(&models.DownloadBaseURL{}).Where("id = ?", id).Update("is_default", true)
|
||||
db.Model(&models.DownloadBaseURL{}).Where("id = ?", req.ID).Update("is_default", true)
|
||||
}
|
||||
|
||||
func deleteDownloadBaseURL(db *gorm.DB, c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||
db.Delete(&models.DownloadBaseURL{}, id)
|
||||
func deleteDownloadBaseURL(db *gorm.DB, req downloadSettingsRequest) {
|
||||
db.Delete(&models.DownloadBaseURL{}, req.ID)
|
||||
}
|
||||
|
||||
// ---------------- Comment settings ----------------
|
||||
// ---------------- 评论设置 ----------------
|
||||
|
||||
// CommentSettingsPage renders the comment policy form.
|
||||
// CommentSettingsPage 渲染评论策略表单。
|
||||
func CommentSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
@@ -410,28 +570,40 @@ func CommentSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// CommentSettingsSave persists the comment policy toggles and refreshes the
|
||||
// in-memory cache so subsequent requests see the change.
|
||||
// commentSettingsRequest 是 POST /api/admin/settings/comments 的 JSON 请求体。
|
||||
type commentSettingsRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AllowGuest bool `json:"allow_guest"`
|
||||
GuestRequireApproval bool `json:"guest_require_approval"`
|
||||
UseGravatar bool `json:"use_gravatar"`
|
||||
}
|
||||
|
||||
// CommentSettingsSave 持久化评论策略开关并刷新内存缓存,
|
||||
// 使后续请求能看到变更。
|
||||
func CommentSettingsSave(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req commentSettingsRequest
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
var cc models.CommentConfig
|
||||
if err := db.First(&cc, 1).Error; err != nil {
|
||||
cc = models.CommentConfig{ID: 1}
|
||||
}
|
||||
cc.Enabled = c.PostForm("enabled") == "1"
|
||||
cc.AllowGuest = c.PostForm("allow_guest") == "1"
|
||||
cc.GuestRequireApproval = c.PostForm("guest_require_approval") == "1"
|
||||
cc.UseGravatar = c.PostForm("use_gravatar") == "1"
|
||||
cc.Enabled = req.Enabled
|
||||
cc.AllowGuest = req.AllowGuest
|
||||
cc.GuestRequireApproval = req.GuestRequireApproval
|
||||
cc.UseGravatar = req.UseGravatar
|
||||
cc.UpdatedBy = userIDFromSession(c)
|
||||
db.Save(&cc)
|
||||
models.RefreshConfigCache(db)
|
||||
c.Redirect(http.StatusFound, "/admin/settings/comments?saved=1")
|
||||
APIOK(c, "/admin/settings/comments?saved=1", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Navigation Links settings ----------------
|
||||
// ---------------- 导航链接设置 ----------------
|
||||
|
||||
// NavLinksSettingsPage renders the navigation links management page.
|
||||
// NavLinksSettingsPage 渲染导航链接管理页面。
|
||||
func NavLinksSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
@@ -448,68 +620,88 @@ func NavLinksSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// NavLinksSettingsSave dispatches navigation link actions.
|
||||
// navLinkSettingsRequest 是 POST /api/admin/settings/navlinks 的 JSON 请求体。
|
||||
// action 分发:add / toggle / edit / delete。
|
||||
type navLinkSettingsRequest struct {
|
||||
Action string `json:"action"`
|
||||
ID int `json:"id"`
|
||||
TitleZh string `json:"title_zh"`
|
||||
TitleEn string `json:"title_en"`
|
||||
URL string `json:"url"`
|
||||
Sort int `json:"sort"`
|
||||
OpenNew bool `json:"open_new"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// NavLinksSettingsSave 分发导航链接操作。
|
||||
func NavLinksSettingsSave(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
switch c.PostForm("action") {
|
||||
var req navLinkSettingsRequest
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
switch req.Action {
|
||||
case "add":
|
||||
addNavLink(db, c)
|
||||
addNavLink(db, req, userIDFromSession(c))
|
||||
case "toggle":
|
||||
toggleNavLink(db, c)
|
||||
toggleNavLink(db, req, userIDFromSession(c))
|
||||
case "edit":
|
||||
editNavLink(db, c)
|
||||
editNavLink(db, req, userIDFromSession(c))
|
||||
case "delete":
|
||||
deleteNavLink(db, c)
|
||||
deleteNavLink(db, req)
|
||||
default:
|
||||
APIError(c, http.StatusBadRequest, "api_invalid_request")
|
||||
return
|
||||
}
|
||||
models.RefreshConfigCache(db)
|
||||
c.Redirect(http.StatusFound, "/admin/settings/navlinks?saved=1")
|
||||
APIOK(c, "/admin/settings/navlinks?saved=1", nil)
|
||||
}
|
||||
}
|
||||
|
||||
func addNavLink(db *gorm.DB, c *gin.Context) {
|
||||
titleZh := strings.TrimSpace(c.PostForm("title_zh"))
|
||||
titleEn := strings.TrimSpace(c.PostForm("title_en"))
|
||||
url := strings.TrimSpace(c.PostForm("url"))
|
||||
func addNavLink(db *gorm.DB, req navLinkSettingsRequest, updatedBy uint) {
|
||||
titleZh := strings.TrimSpace(req.TitleZh)
|
||||
titleEn := strings.TrimSpace(req.TitleEn)
|
||||
url := strings.TrimSpace(req.URL)
|
||||
|
||||
if url == "" || (titleZh == "" && titleEn == "") {
|
||||
return
|
||||
}
|
||||
|
||||
sort, _ := strconv.Atoi(c.PostForm("sort"))
|
||||
|
||||
enabled := true // 未提交 Enabled 时默认启用
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
link := models.NavLink{
|
||||
TitleZh: titleZh,
|
||||
TitleEn: titleEn,
|
||||
URL: url,
|
||||
OpenNew: c.PostForm("open_new") == "1",
|
||||
Enabled: c.PostForm("enabled") != "0",
|
||||
Sort: sort,
|
||||
UpdatedBy: userIDFromSession(c),
|
||||
TitleZh: titleZh,
|
||||
TitleEn: titleEn,
|
||||
URL: url,
|
||||
OpenNew: req.OpenNew,
|
||||
Enabled: enabled,
|
||||
Sort: req.Sort,
|
||||
UpdatedBy: updatedBy,
|
||||
}
|
||||
db.Create(&link)
|
||||
}
|
||||
|
||||
func toggleNavLink(db *gorm.DB, c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||
func toggleNavLink(db *gorm.DB, req navLinkSettingsRequest, updatedBy uint) {
|
||||
var link models.NavLink
|
||||
if db.First(&link, id).Error != nil {
|
||||
if db.First(&link, req.ID).Error != nil {
|
||||
return
|
||||
}
|
||||
link.Enabled = !link.Enabled
|
||||
link.UpdatedBy = userIDFromSession(c)
|
||||
link.UpdatedBy = updatedBy
|
||||
db.Save(&link)
|
||||
}
|
||||
|
||||
func editNavLink(db *gorm.DB, c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||
func editNavLink(db *gorm.DB, req navLinkSettingsRequest, updatedBy uint) {
|
||||
var link models.NavLink
|
||||
if db.First(&link, id).Error != nil {
|
||||
if db.First(&link, req.ID).Error != nil {
|
||||
return
|
||||
}
|
||||
|
||||
titleZh := strings.TrimSpace(c.PostForm("title_zh"))
|
||||
titleEn := strings.TrimSpace(c.PostForm("title_en"))
|
||||
url := strings.TrimSpace(c.PostForm("url"))
|
||||
titleZh := strings.TrimSpace(req.TitleZh)
|
||||
titleEn := strings.TrimSpace(req.TitleEn)
|
||||
url := strings.TrimSpace(req.URL)
|
||||
|
||||
if url == "" || (titleZh == "" && titleEn == "") {
|
||||
return
|
||||
@@ -518,14 +710,12 @@ func editNavLink(db *gorm.DB, c *gin.Context) {
|
||||
link.TitleZh = titleZh
|
||||
link.TitleEn = titleEn
|
||||
link.URL = url
|
||||
link.OpenNew = c.PostForm("open_new") == "1"
|
||||
link.Sort, _ = strconv.Atoi(c.PostForm("sort"))
|
||||
link.UpdatedBy = userIDFromSession(c)
|
||||
link.OpenNew = req.OpenNew
|
||||
link.Sort = req.Sort
|
||||
link.UpdatedBy = updatedBy
|
||||
db.Save(&link)
|
||||
}
|
||||
|
||||
func deleteNavLink(db *gorm.DB, c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||
db.Delete(&models.NavLink{}, id)
|
||||
func deleteNavLink(db *gorm.DB, req navLinkSettingsRequest) {
|
||||
db.Delete(&models.NavLink{}, req.ID)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// TestSiteImageUploadRejectsMismatchedContent 覆盖 SECURITY_TODO #29:
|
||||
// favicon/logo 上传必须通过魔数一致性校验——PNG 扩展名携带 HTML 字节
|
||||
// 返回 400 且不落盘;真实 PNG 成功保存。
|
||||
func TestSiteImageUploadRejectsMismatchedContent(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
seedUploadType(t, e, ".png", models.CategoryImage)
|
||||
// 配置图片 MIME 策略,使内容校验有据可依(种子行的策略为空值,宽容放行)。
|
||||
e.db.Model(&models.UploadFileType{}).Where("extension = ?", ".png").Update("mime_type", "image/png")
|
||||
models.LoadConfigCache(e.db)
|
||||
|
||||
admin := e.login(t, "admin")
|
||||
token := e.csrfTokenFor(t, admin)
|
||||
|
||||
for _, endpoint := range []struct{ path, field string }{
|
||||
{"/api/admin/settings/site/favicon", "favicon"},
|
||||
{"/api/admin/settings/site/logo", "logo"},
|
||||
} {
|
||||
// .png 扩展名 + HTML 字节 → 400(内容与声明类型不匹配)。
|
||||
w := e.multipartUpload(t, endpoint.path, admin, token, endpoint.field,
|
||||
"logo.png", []byte("<html><script>alert(1)</script></html>"), nil)
|
||||
if w.Code != http.StatusBadRequest || respCode(w) != "settings_upload_bad_content" {
|
||||
t.Fatalf("%s mismatched content: status = %d, code = %q, want 400/settings_upload_bad_content",
|
||||
endpoint.path, w.Code, respCode(w))
|
||||
}
|
||||
|
||||
// 真实 PNG → 200。
|
||||
w = e.multipartUpload(t, endpoint.path, admin, token, endpoint.field,
|
||||
"logo.png", pngBytes(t), nil)
|
||||
if w.Code != http.StatusOK || !respOK(w) {
|
||||
t.Fatalf("%s valid png: status = %d, body %s", endpoint.path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,29 +7,31 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// ErrUploadsDisabled is returned when the global upload switch is off.
|
||||
// ErrUploadsDisabled 在全局上传开关关闭时返回。
|
||||
var ErrUploadsDisabled = errors.New("uploads are disabled")
|
||||
|
||||
// FileValidationError describes why an uploaded file was rejected.
|
||||
// FileValidationError 描述上传文件被拒绝的原因。
|
||||
type FileValidationError struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *FileValidationError) Error() string { return e.Reason }
|
||||
|
||||
// FileCheck is the outcome of validating an uploaded file header.
|
||||
// FileCheck 是验证上传文件头的结果。
|
||||
type FileCheck struct {
|
||||
OK bool
|
||||
Type *models.UploadFileType // matched type, nil if not found
|
||||
MaxSize int64 // effective byte limit applied
|
||||
Type *models.UploadFileType // 匹配的类型,未匹配时为 nil
|
||||
MaxSize int64 // 生效的字节限制
|
||||
}
|
||||
|
||||
// ValidateUpload checks a file header against the cached platform upload
|
||||
// policy: master switch, extension whitelist, and per-type size limit. The
|
||||
// reported MaxSize is the effective limit (per-type override, else default).
|
||||
// ValidateUpload 依据缓存的平台上传策略校验文件头:
|
||||
// 总开关、扩展名白名单以及按类型的单文件大小限制。
|
||||
// 报告的 MaxSize 是生效限制(按类型覆盖,否则使用默认值)。
|
||||
func ValidateUpload(header *multipart.FileHeader) FileCheck {
|
||||
cfg := models.GetUploadConfig()
|
||||
|
||||
@@ -53,11 +55,11 @@ func ValidateUpload(header *multipart.FileHeader) FileCheck {
|
||||
}
|
||||
}
|
||||
|
||||
// Extension not in the whitelist.
|
||||
// 扩展名不在白名单内。
|
||||
return FileCheck{OK: false, MaxSize: def}
|
||||
}
|
||||
|
||||
// formatSize renders a byte count as a human-readable string.
|
||||
// formatSize 将字节数渲染为人类可读的字符串。
|
||||
func formatSize(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
@@ -70,3 +72,31 @@ func formatSize(b int64) string {
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// contentMatchesType 将上传字节的魔数与匹配扩展名配置的 MIME 类型比对
|
||||
// (SECURITY_TODO #14)。它有意保持宽松:空/未知的 MIME 策略和无法识别的
|
||||
// 内容均可通过(扩展名白名单仍是主要关卡);声称是 .txt 却携带 PNG 字节
|
||||
// 的文件会被拒绝。
|
||||
func contentMatchesType(t *models.UploadFileType, content []byte) bool {
|
||||
if t == nil || len(content) == 0 {
|
||||
return true
|
||||
}
|
||||
expected := strings.ToLower(strings.TrimSpace(t.MimeType))
|
||||
if expected == "" || expected == "application/octet-stream" {
|
||||
// 无具体策略,或管理员明确允许任意二进制内容。
|
||||
return true
|
||||
}
|
||||
// 去除管理员可能复制过来的 charset 参数。
|
||||
if i := strings.Index(expected, ";"); i >= 0 {
|
||||
expected = strings.TrimSpace(expected[:i])
|
||||
}
|
||||
if expected == "" {
|
||||
return true
|
||||
}
|
||||
det := mimetype.Detect(content)
|
||||
if det == nil || det.String() == "" {
|
||||
// 内容无法识别(如特殊的 Unicode 文本);仅由头部策略把关。
|
||||
return true
|
||||
}
|
||||
return det.Is(expected)
|
||||
}
|
||||
+80
-34
@@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Lang represents a supported language code.
|
||||
// Lang 表示一种受支持的语言代码。
|
||||
type Lang string
|
||||
|
||||
const (
|
||||
@@ -12,17 +12,17 @@ const (
|
||||
ZH Lang = "zh"
|
||||
)
|
||||
|
||||
// translations holds all UI strings keyed by language then translation key.
|
||||
// translations 保存全部 UI 字符串,按语言、再按翻译键索引。
|
||||
var translations = map[Lang]map[string]string{
|
||||
EN: {
|
||||
// Nav
|
||||
// 导航
|
||||
"site_title": "Go Blog",
|
||||
"home": "Home",
|
||||
"login": "Login",
|
||||
"dashboard": "Dashboard",
|
||||
"logout": "Logout",
|
||||
|
||||
// Home page
|
||||
// 首页
|
||||
"home_welcome": "Welcome to Go Blog",
|
||||
"home_subtitle": "A simple, fast blog engine built with Go, Gin, and Tailwind CSS.",
|
||||
"home_sign_in": "Sign In",
|
||||
@@ -37,7 +37,7 @@ var translations = map[Lang]map[string]string{
|
||||
"home_post3_dsc": "This blog engine uses Go, Gin web framework, GORM, and Tailwind CSS for a modern experience.",
|
||||
"home_no_posts": "No published posts yet.",
|
||||
|
||||
// Login page
|
||||
// 登录页
|
||||
"login_title": "Sign In",
|
||||
"login_username": "Username",
|
||||
"login_password": "Password",
|
||||
@@ -45,11 +45,12 @@ var translations = map[Lang]map[string]string{
|
||||
"login_ph_pass": "Enter your password",
|
||||
"login_submit": "Sign In",
|
||||
"login_error": "Invalid username or password.",
|
||||
"login_locked": "Too many failed attempts. Please try again in 15 minutes.",
|
||||
"login_required": "Please fill in all fields.",
|
||||
"login_no_account": "Don't have an account?",
|
||||
"login_register_link": "Register",
|
||||
|
||||
// Register page
|
||||
// 注册页
|
||||
"page_register": "Register",
|
||||
"register_title": "Create Account",
|
||||
"register_username": "Username",
|
||||
@@ -73,13 +74,17 @@ var translations = map[Lang]map[string]string{
|
||||
"register_username_length": "Username must be 3-32 characters.",
|
||||
"register_password_length": "Password must be at least 6 characters.",
|
||||
"register_password_mismatch": "Passwords do not match.",
|
||||
"register_error": "Registration failed. Please try again.",
|
||||
"register_email_invalid": "Please enter a valid email address.",
|
||||
"register_error": "Registration failed. Please try again.",
|
||||
"registration_disabled": "Registration is currently disabled.",
|
||||
|
||||
// Settings
|
||||
// 设置
|
||||
"settings_allow_registration": "Allow user registration",
|
||||
"settings_allow_registration_hint": "When enabled, visitors can create their own accounts from the login page",
|
||||
"settings_site_url": "Canonical site URL (used in RSS feeds)",
|
||||
"settings_site_url_hint": "Used as the base URL in RSS links. Leave blank to fall back to the request Host (legacy).",
|
||||
|
||||
// Dashboard
|
||||
// 后台
|
||||
"dash_title": "Dashboard",
|
||||
"dash_welcome": "Welcome back,",
|
||||
"dash_posts": "Posts",
|
||||
@@ -91,17 +96,17 @@ var translations = map[Lang]map[string]string{
|
||||
"dash_new_article": "New Article",
|
||||
"dash_page_title": "Dashboard",
|
||||
|
||||
// Footer
|
||||
// 页脚
|
||||
"footer_text": "© 2026 Go Blog. Powered by Go & Gin.",
|
||||
|
||||
// Page titles
|
||||
// 页面标题
|
||||
"page_home": "Home",
|
||||
"page_login": "Login",
|
||||
|
||||
// Language switcher
|
||||
// 语言切换器
|
||||
"lang_switch": "中文",
|
||||
|
||||
// Profile dropdown & page
|
||||
// 个人中心下拉菜单 & 页面
|
||||
"profile": "Profile",
|
||||
"admin_panel": "Admin Panel",
|
||||
"my_articles": "My Articles",
|
||||
@@ -118,6 +123,8 @@ var translations = map[Lang]map[string]string{
|
||||
"profile_save": "Save Changes",
|
||||
"profile_saved": "Profile updated.",
|
||||
"profile_wrong_password": "Current password is incorrect.",
|
||||
"profile_password_short": "New password must be at least 6 characters.",
|
||||
"profile_email_invalid": "Please enter a valid email address.",
|
||||
"profile_upload_invalid": "File type not allowed.",
|
||||
"profile_upload_disabled": "Uploads are currently disabled.",
|
||||
"profile_upload_too_large": "File is too large. Limit: %s",
|
||||
@@ -125,7 +132,7 @@ var translations = map[Lang]map[string]string{
|
||||
"gender_female": "Female",
|
||||
"gender_other": "Other",
|
||||
|
||||
// Avatar cropping
|
||||
// 头像裁剪
|
||||
"crop_avatar_title": "Crop Avatar",
|
||||
"crop_cancel": "Cancel",
|
||||
"crop_confirm": "Confirm",
|
||||
@@ -133,7 +140,7 @@ var translations = map[Lang]map[string]string{
|
||||
"crop_success": "Avatar updated.",
|
||||
"crop_error": "Failed to upload avatar. Please try again.",
|
||||
|
||||
// Article creation
|
||||
// 文章创建
|
||||
"article_create_title": "Create Article",
|
||||
"article_field_title": "Title",
|
||||
"article_title": "Title",
|
||||
@@ -179,7 +186,7 @@ var translations = map[Lang]map[string]string{
|
||||
"article_att_error": "Upload failed. Please try again.",
|
||||
"article_att_delete_confirm": "Delete this attachment?",
|
||||
|
||||
// Article management
|
||||
// 文章管理
|
||||
"article_list_title": "Articles",
|
||||
"my_articles_title": "My Articles",
|
||||
"article_edit_title": "Edit Article",
|
||||
@@ -196,21 +203,21 @@ var translations = map[Lang]map[string]string{
|
||||
"article_col_actions": "Actions",
|
||||
"article_last_updated": "Last Updated",
|
||||
|
||||
// Tags
|
||||
// 标签
|
||||
"tags_title": "Tags",
|
||||
"article_tags": "Tags",
|
||||
"article_tags_hint": "Comma-separated tag names, e.g., golang, web, tutorial",
|
||||
"tag_filter": "Filter by tag",
|
||||
"tag_all": "All",
|
||||
|
||||
// Search
|
||||
// 搜索
|
||||
"search_placeholder": "Search articles...",
|
||||
"search_title": "Search Results",
|
||||
"search_results_for": "Search results for",
|
||||
"search_no_results": "No articles found matching your search.",
|
||||
"search_keyword": "Keyword",
|
||||
|
||||
// Settings (platform configuration)
|
||||
// 平台配置设置
|
||||
"settings_nav": "Platform Settings",
|
||||
"settings_saved": "Settings saved.",
|
||||
"settings_site_title": "Site Settings",
|
||||
@@ -254,6 +261,8 @@ var translations = map[Lang]map[string]string{
|
||||
"settings_save": "Save",
|
||||
"settings_upload_title": "Upload Settings",
|
||||
"settings_upload_desc": "Attachment upload policy and permitted file types.",
|
||||
"settings_upload_dangerous_ext": "This extension is not allowed: files of this type can execute active content in the site's origin.",
|
||||
"settings_upload_illegal_dir": "Storage sub-directory is not allowed: use a single segment of letters, digits, '_' or '-' only.",
|
||||
"settings_uploads_enabled":"Enable attachments",
|
||||
"settings_default_size": "Default max size (MB)",
|
||||
"settings_storage_dir": "Storage sub-directory",
|
||||
@@ -282,7 +291,7 @@ var translations = map[Lang]map[string]string{
|
||||
"cat_video": "Video",
|
||||
"cat_other": "Other",
|
||||
|
||||
// Comments
|
||||
// 评论
|
||||
"comments_title": "Comments",
|
||||
"comments_count": "%d Comments",
|
||||
"comments_empty": "No comments yet. Be the first to comment.",
|
||||
@@ -322,7 +331,7 @@ var translations = map[Lang]map[string]string{
|
||||
"comments_markdown_link": "[text](url)",
|
||||
"comments_markdown_quote": "> quote",
|
||||
|
||||
// Admin: comments
|
||||
// 后台:评论
|
||||
"admin_comments": "Comments",
|
||||
"comment_manage": "Manage Comments",
|
||||
"admin_comments_title": "Comments",
|
||||
@@ -345,7 +354,7 @@ var translations = map[Lang]map[string]string{
|
||||
"comment_rejected": "Comment rejected.",
|
||||
"comment_deleted": "Comment deleted.",
|
||||
|
||||
// Admin: comment settings
|
||||
// 后台:评论设置
|
||||
"comment_settings_title": "Comment Settings",
|
||||
"comment_settings_desc": "Control whether comments are enabled and how they are moderated.",
|
||||
"comment_settings_enabled": "Enable comments",
|
||||
@@ -354,7 +363,7 @@ var translations = map[Lang]map[string]string{
|
||||
"comment_settings_use_gravatar": "Use Gravatar avatars",
|
||||
"comment_settings_use_gravatar_hint": "When off, avatars show the author's initial on a colored background.",
|
||||
|
||||
// Admin: user management
|
||||
// 后台:用户管理
|
||||
"admin_users": "Users",
|
||||
"admin_users_title": "Users",
|
||||
"user_list_title": "User Management",
|
||||
@@ -390,13 +399,15 @@ var translations = map[Lang]map[string]string{
|
||||
"user_empty": "No users.",
|
||||
"user_username_required": "Username is required.",
|
||||
"user_password_required": "Password is required.",
|
||||
"user_password_short": "Password must be at least 6 characters.",
|
||||
"user_email_invalid": "Please enter a valid email address.",
|
||||
"user_username_exists": "Username already exists.",
|
||||
"user_cannot_delete_self": "You cannot delete your own account.",
|
||||
"user_cannot_disable_self": "You cannot disable or lock your own account.",
|
||||
"user_cannot_remove_last_admin": "Cannot remove the last administrator.",
|
||||
"dash_user_mgmt": "Manage Users",
|
||||
|
||||
// Analytics
|
||||
// 阅读统计
|
||||
"analytics_views_title": "Reading Analytics",
|
||||
"analytics_views_desc": "Track article views, identify unique visitors and detect bots.",
|
||||
"analytics_stats_total": "Total Views",
|
||||
@@ -433,6 +444,18 @@ var translations = map[Lang]map[string]string{
|
||||
"analytics_showing": "Showing",
|
||||
"analytics_of": "of",
|
||||
"analytics_load_more": "Load More",
|
||||
|
||||
// API(/api/* JSON 接口通用)
|
||||
"api_error": "Request failed.",
|
||||
"api_unauthorized": "Please sign in first.",
|
||||
"api_forbidden": "You do not have permission to perform this action.",
|
||||
"api_invalid_request": "Invalid request body.",
|
||||
"request_too_large": "The request body exceeds the size limit.",
|
||||
"register_locked": "Too many registration attempts from your address. Please try again later.",
|
||||
"comments_locked": "Too many comments from your address. Please wait a moment and try again.",
|
||||
"settings_upload_bad_content": "File content does not match its declared type.",
|
||||
"user_status_invalid": "Invalid user status.",
|
||||
"user_not_found": "User not found.",
|
||||
},
|
||||
ZH: {
|
||||
// 导航
|
||||
@@ -465,6 +488,7 @@ var translations = map[Lang]map[string]string{
|
||||
"login_ph_pass": "请输入密码",
|
||||
"login_submit": "登录",
|
||||
"login_error": "用户名或密码错误。",
|
||||
"login_locked": "尝试次数过多,请15分钟后再试。",
|
||||
"login_required": "请填写所有字段。",
|
||||
"login_no_account": "还没有账号?",
|
||||
"login_register_link": "注册",
|
||||
@@ -493,11 +517,15 @@ var translations = map[Lang]map[string]string{
|
||||
"register_username_length": "用户名必须是3-32个字符。",
|
||||
"register_password_length": "密码至少需要6个字符。",
|
||||
"register_password_mismatch": "两次输入的密码不一致。",
|
||||
"register_error": "注册失败,请重试。",
|
||||
"register_email_invalid": "请输入有效的邮箱地址。",
|
||||
"register_error": "注册失败,请重试。",
|
||||
"registration_disabled": "当前已关闭注册。",
|
||||
|
||||
// 平台设置
|
||||
"settings_allow_registration": "允许用户注册",
|
||||
"settings_allow_registration_hint": "启用后,访客可以从登录页面创建自己的账号",
|
||||
"settings_site_url": "站点规范地址(用于 RSS)",
|
||||
"settings_site_url_hint": "RSS 链接将使用该地址作为前缀。留空则回退为请求 Host(旧行为)。",
|
||||
|
||||
// 后台
|
||||
"dash_title": "后台管理",
|
||||
@@ -536,6 +564,8 @@ var translations = map[Lang]map[string]string{
|
||||
"profile_save": "保存修改",
|
||||
"profile_saved": "个人信息已更新。",
|
||||
"profile_wrong_password": "当前密码错误。",
|
||||
"profile_password_short": "新密码至少需要6个字符。",
|
||||
"profile_email_invalid": "请输入有效的邮箱地址。",
|
||||
"profile_upload_invalid": "不允许的文件类型。",
|
||||
"profile_upload_disabled": "上传功能已关闭。",
|
||||
"profile_upload_too_large": "文件过大。限制:%s",
|
||||
@@ -672,6 +702,8 @@ var translations = map[Lang]map[string]string{
|
||||
"settings_save": "保存",
|
||||
"settings_upload_title": "上传设置",
|
||||
"settings_upload_desc": "附件上传策略与允许的文件类型。",
|
||||
"settings_upload_dangerous_ext": "不允许该扩展名:此类文件可在站点同源执行活动内容。",
|
||||
"settings_upload_illegal_dir": "存储子目录不合法:只能使用字母、数字、'_' 或 '-' 的单个路径段。",
|
||||
"settings_uploads_enabled":"启用附件上传",
|
||||
"settings_default_size": "默认最大大小(MB)",
|
||||
"settings_storage_dir": "存储子目录",
|
||||
@@ -808,6 +840,8 @@ var translations = map[Lang]map[string]string{
|
||||
"user_empty": "暂无用户。",
|
||||
"user_username_required": "请填写用户名。",
|
||||
"user_password_required": "请填写密码。",
|
||||
"user_password_short": "密码至少需要6个字符。",
|
||||
"user_email_invalid": "请输入有效的邮箱地址。",
|
||||
"user_username_exists": "用户名已存在。",
|
||||
"user_cannot_delete_self": "不能删除自己的账号。",
|
||||
"user_cannot_disable_self": "不能禁用或锁定自己的账号。",
|
||||
@@ -851,17 +885,29 @@ var translations = map[Lang]map[string]string{
|
||||
"analytics_showing": "显示",
|
||||
"analytics_of": "共",
|
||||
"analytics_load_more": "加载更多",
|
||||
|
||||
// API(/api/* JSON 接口通用)
|
||||
"api_error": "请求失败,请稍后重试。",
|
||||
"api_unauthorized": "请先登录。",
|
||||
"api_forbidden": "您没有权限执行此操作。",
|
||||
"api_invalid_request": "请求参数格式不正确。",
|
||||
"request_too_large": "请求体超过大小限制。",
|
||||
"register_locked": "来自该地址的注册次数过多,请稍后再试。",
|
||||
"comments_locked": "评论提交过于频繁,请稍后再试。",
|
||||
"settings_upload_bad_content": "文件内容与声明类型不匹配。",
|
||||
"user_status_invalid": "无效的用户状态。",
|
||||
"user_not_found": "用户不存在。",
|
||||
},
|
||||
}
|
||||
|
||||
// T returns a copy of the translation map for the given language.
|
||||
// Falls back to English if the language is not supported.
|
||||
// T 返回指定语言的翻译映射副本。
|
||||
// 若语言不受支持则回退到英语。
|
||||
func T(l Lang) map[string]string {
|
||||
t, ok := translations[l]
|
||||
if !ok {
|
||||
t = translations[EN]
|
||||
}
|
||||
// Return a shallow copy so callers cannot mutate the original map values.
|
||||
// 返回浅拷贝,使调用方无法修改原始映射的值。
|
||||
out := make(map[string]string, len(t))
|
||||
for k, v := range t {
|
||||
out[k] = v
|
||||
@@ -869,23 +915,23 @@ func T(l Lang) map[string]string {
|
||||
return out
|
||||
}
|
||||
|
||||
// DetectLang parses the Accept-Language header and returns the best matching
|
||||
// supported language. Returns EN for unsupported languages.
|
||||
// DetectLang 解析 Accept-Language 请求头,返回最匹配的受支持语言。
|
||||
// 对不受支持的语言返回 EN。
|
||||
func DetectLang(acceptHeader string) Lang {
|
||||
if acceptHeader == "" {
|
||||
return EN
|
||||
}
|
||||
|
||||
// Accept-Language format: "zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7"
|
||||
// Split by comma, then extract primary language from each entry.
|
||||
// Accept-Language 格式:"zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7"
|
||||
// 按逗号拆分,然后从每个条目中提取主要语言。
|
||||
entries := strings.Split(acceptHeader, ",")
|
||||
for _, entry := range entries {
|
||||
// Trim spaces and remove quality values.
|
||||
// 去除空格并移除质量(q)值。
|
||||
entry = strings.TrimSpace(entry)
|
||||
if idx := strings.Index(entry, ";"); idx != -1 {
|
||||
entry = entry[:idx]
|
||||
}
|
||||
// Extract primary language (before any "-" subtag).
|
||||
// 提取主要语言(任何 "-" 子标签之前的部分)。
|
||||
primary := strings.ToLower(entry)
|
||||
if idx := strings.Index(primary, "-"); idx != -1 {
|
||||
primary = primary[:idx]
|
||||
|
||||
+9
-7
@@ -22,7 +22,7 @@ echo "拉取最新代码..."
|
||||
git pull
|
||||
|
||||
echo "编译 Go 程序..."
|
||||
go build -o "${BINARY_NAME}" .
|
||||
go build -ldflags "-X go_blog/buildinfo.Commit=$(git rev-parse --short HEAD) -X go_blog/buildinfo.BuildTime=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" -o "${BINARY_NAME}" .
|
||||
|
||||
echo "检查系统用户..."
|
||||
if ! id -u "${SERVICE_USER}" >/dev/null 2>&1; then
|
||||
@@ -40,15 +40,11 @@ install -m 0755 -o root -g root "${SCRIPT_DIR}/${BINARY_NAME}" "${INSTALL_DIR}/$
|
||||
rm -rf "${INSTALL_DIR}/templates"
|
||||
cp -a "${SCRIPT_DIR}/templates" "${INSTALL_DIR}/templates"
|
||||
chown -R root:root "${INSTALL_DIR}/templates"
|
||||
rm -rf "${INSTALL_DIR}/static"
|
||||
cp -a "${SCRIPT_DIR}/static" "${INSTALL_DIR}/static"
|
||||
chown -R root:root "${INSTALL_DIR}/static"
|
||||
# static 资源已通过 go:embed 编入二进制,无需单独拷贝
|
||||
chown "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_DIR}"
|
||||
chmod 0755 "${INSTALL_DIR}"
|
||||
find "${INSTALL_DIR}/templates" -type d -exec chmod 0755 {} \;
|
||||
find "${INSTALL_DIR}/templates" -type f -exec chmod 0644 {} \;
|
||||
find "${INSTALL_DIR}/static" -type d -exec chmod 0755 {} \;
|
||||
find "${INSTALL_DIR}/static" -type f -exec chmod 0644 {} \;
|
||||
|
||||
SOCKET_PATH="${SOCKET_DIR}/web.sock"
|
||||
|
||||
@@ -81,7 +77,7 @@ User=${SERVICE_USER}
|
||||
Group=${SERVICE_USER}
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
ExecStart=${INSTALL_DIR}/${BINARY_NAME} -config ${CONFIG_DIR}/config.yaml
|
||||
ExecStartPost=/bin/sh -c 'while [ ! -S ${SOCKET_DIR}/web.sock ]; do sleep 0.1; done; chmod 666 ${SOCKET_DIR}/web.sock'
|
||||
ExecStartPost=/bin/sh -c 'while [ ! -S ${SOCKET_DIR}/web.sock ]; do sleep 0.1; done; chown ${SERVICE_USER}:${SERVICE_USER} ${SOCKET_DIR}/web.sock; chmod 660 ${SOCKET_DIR}/web.sock'
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
NoNewPrivileges=true
|
||||
@@ -101,3 +97,9 @@ systemctl restart "${SERVICE_NAME}"
|
||||
|
||||
echo "部署完成,服务状态:"
|
||||
systemctl --no-pager --full status "${SERVICE_NAME}"
|
||||
|
||||
echo ""
|
||||
echo "重要提示:unix socket 权限现为 660(组: ${SERVICE_USER}),"
|
||||
echo "请将反向代理(caddy/nginx)运行用户加入 ${SERVICE_USER} 组:"
|
||||
echo " sudo usermod -aG ${SERVICE_USER} <proxy_user>"
|
||||
echo "然后重启代理服务,否则代理无法读取 socket(本机其他用户也无法再直连)。"
|
||||
@@ -1,176 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go_blog/config"
|
||||
"go_blog/handlers"
|
||||
"go_blog/middleware"
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// staticFiles 将静态资源(Markdown CSS/JS)嵌入二进制文件,
|
||||
// 使部署只需替换可执行文件——无需向服务器复制独立的静态目录。
|
||||
//
|
||||
//go:embed static
|
||||
var staticFiles embed.FS
|
||||
|
||||
func main() {
|
||||
// 0. Parse command-line flags.
|
||||
// 0. 解析命令行参数。
|
||||
configFlag := flag.String("config", "", "path to config file (default: OS-aware path)")
|
||||
flag.Parse()
|
||||
|
||||
// 1. Load configuration (auto-creates if missing).
|
||||
// 1. 加载配置(不存在时自动创建)。
|
||||
cfg := config.LoadConfig(*configFlag)
|
||||
|
||||
// 2. Initialize the database (auto-migrates, seeds admin).
|
||||
// 2. 初始化数据库(自动迁移、初始化管理员)。
|
||||
db := models.InitDB(cfg)
|
||||
|
||||
// 2b. Warm the platform configuration cache from the database.
|
||||
// 2b. 从数据库预热平台配置缓存。
|
||||
models.LoadConfigCache(db)
|
||||
|
||||
// 3. Create session store (cookie-based).
|
||||
// 3. 创建会话存储(基于 Cookie)。
|
||||
store := cookie.NewStore([]byte(cfg.Secret))
|
||||
// 登录速率限制器(SECURITY_TODO #10):按 IP+用户名计数失败次数,
|
||||
// 使 libcurl/字典攻击者无法猛攻登录端点。
|
||||
loginLimiter := handlers.NewLoginLimiter()
|
||||
store.Options(sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 86400, // 24 hours
|
||||
HttpOnly: true, // prevent XSS access
|
||||
Secure: false, // set true in production with HTTPS
|
||||
MaxAge: 86400, // 24 小时
|
||||
HttpOnly: true, // 防止 XSS 访问
|
||||
SameSite: http.SameSiteLaxMode, // CSRF 纵深防御;令牌校验是主控措施
|
||||
// Secure 在下方的中间件中按请求设置(仅 HTTPS 时)。
|
||||
})
|
||||
|
||||
// 4. Create Gin router.
|
||||
// 4. 创建 Gin 路由器。
|
||||
router := gin.Default()
|
||||
|
||||
// 5. Load HTML templates.
|
||||
// 4b. 可信代理:只有列表中的 IP 才能影响客户端 IP(X-Forwarded-For)。
|
||||
// 如果不设置,gin 会信任所有代理,客户端就能伪造评论/文章浏览
|
||||
// 中记录的 IP。
|
||||
if err := router.SetTrustedProxies(cfg.Web.TrustedProxies); err != nil {
|
||||
log.Fatalf("Invalid trusted_proxies in config: %v", err)
|
||||
}
|
||||
|
||||
// 4c. 安全响应头(最先注册,确保被拒绝的响应上也包含它们)。
|
||||
router.Use(middleware.SecurityHeaders())
|
||||
|
||||
// 5. 加载 HTML 模板。
|
||||
router.LoadHTMLGlob("templates/**/*.html")
|
||||
|
||||
// 6. Serve uploaded files (avatars etc.) from the storage path.
|
||||
router.Static("/uploads", cfg.Path)
|
||||
// 6. 从存储路径提供上传文件(头像等)。只暴露已知的上传子目录——
|
||||
// 绝不暴露存储根目录本身,其中还包含 SQLite 数据库文件:挂载整个
|
||||
// 根目录会让任何人下载 /uploads/blog.db(SECURITY_TODO #18)。
|
||||
registerUploadRoutes(router.Group("/uploads"), cfg.Path, models.GetUploadConfig().StorageDir)
|
||||
|
||||
// 6b. Serve bundled static assets (CSS/JS for Markdown rendering).
|
||||
router.Static("/static", "./static")
|
||||
// 6b. 提供捆绑的静态资源(内嵌于二进制中)。
|
||||
staticFS, err := fs.Sub(staticFiles, "static")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open embedded static assets: %v", err)
|
||||
}
|
||||
router.StaticFS("/static", http.FS(staticFS))
|
||||
|
||||
// 6. Global session middleware.
|
||||
// 6. 全局会话中间件。
|
||||
router.Use(sessions.Sessions("blog_session", store))
|
||||
|
||||
// 7. Global context middleware (sets IsLoggedIn, Username for templates).
|
||||
// 6a. 按请求的会话 Cookie 加固:仅 HTTPS 时设置 Secure,以及
|
||||
// SameSite=Lax。按请求应用是因为应用位于 TLS 终结端
|
||||
//(Caddy/Cloudflare)之后,启动时无法得知客户端连接是否加密。
|
||||
router.Use(func(c *gin.Context) {
|
||||
opts := sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 86400,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
if middleware.IsHTTPSRequest(c) {
|
||||
opts.Secure = true
|
||||
}
|
||||
sessions.Default(c).Options(opts)
|
||||
})
|
||||
|
||||
// 6b. 全局上下文中间件(为模板设置 IsLoggedIn、Username 等)。
|
||||
// 先于 CSRF 注册:BodyLimit 的 413 文案按请求语言翻译,
|
||||
// 需要这时 tr 已注入上下文。
|
||||
router.Use(middleware.SetUserContext(db))
|
||||
|
||||
// 8. Register routes.
|
||||
router.GET("/", handlers.HomePage(db))
|
||||
router.GET("/search", handlers.SearchPage(db))
|
||||
router.GET("/api/articles", handlers.HomeArticlesAPI(db))
|
||||
router.GET("/rss", handlers.RSSFeed(db))
|
||||
router.GET("/feed", handlers.RSSFeed(db))
|
||||
router.GET("/login", handlers.LoginPage())
|
||||
router.POST("/login", handlers.Login(db))
|
||||
router.GET("/register", handlers.RegisterPage(db))
|
||||
router.POST("/register", handlers.Register(db))
|
||||
router.POST("/logout", handlers.Logout())
|
||||
router.GET("/article/:slug", handlers.ArticleDetail(db))
|
||||
router.POST("/article/:slug/comments", handlers.PostComment(db))
|
||||
// 6c. 请求体大小限制(SECURITY_TODO #26):必须在 CSRF 之前注册——
|
||||
// CSRF 解析 multipart 表单会读取整个请求体,不设上限时未认证请求
|
||||
// 即可通过 multipart 解析耗尽内存/磁盘。
|
||||
router.Use(middleware.BodyLimit())
|
||||
|
||||
// Protected admin routes (admin role only).
|
||||
admin := router.Group("/admin")
|
||||
admin.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
||||
{
|
||||
admin.GET("", handlers.AdminDashboard(db))
|
||||
admin.GET("/articles", handlers.ArticleListPage(db))
|
||||
admin.GET("/articles/new", handlers.ArticleCreatePage(db))
|
||||
admin.POST("/articles/new", handlers.ArticleCreate(db))
|
||||
admin.GET("/articles/:id/edit", handlers.ArticleEditPage(db))
|
||||
admin.POST("/articles/:id/edit", handlers.ArticleUpdate(db))
|
||||
admin.POST("/articles/:id/delete", handlers.ArticleDelete(db))
|
||||
// 6d. CSRF 防护(必须在会话中间件之后运行)。
|
||||
router.Use(middleware.CSRFProtect())
|
||||
|
||||
}
|
||||
// 7. 注册路由。
|
||||
registerRoutes(router, cfg, db, loginLimiter)
|
||||
|
||||
// Protected admin comment management routes (admin role only).
|
||||
comments := router.Group("/admin/comments")
|
||||
comments.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
||||
{
|
||||
comments.GET("", handlers.CommentListPage(db))
|
||||
comments.POST("/:id/approve", handlers.CommentApprove(db))
|
||||
comments.POST("/:id/reject", handlers.CommentReject(db))
|
||||
comments.POST("/:id/delete", handlers.CommentDelete(db))
|
||||
}
|
||||
|
||||
// Protected admin user-management routes (admin role only).
|
||||
users := router.Group("/admin/users")
|
||||
users.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
||||
{
|
||||
users.GET("", handlers.UserListPage(db))
|
||||
users.GET("/new", handlers.UserCreatePage(db))
|
||||
users.POST("/new", handlers.UserCreate(db))
|
||||
users.GET("/:id/edit", handlers.UserEditPage(db))
|
||||
users.POST("/:id/edit", handlers.UserUpdate(db))
|
||||
users.POST("/:id/delete", handlers.UserDelete(db))
|
||||
}
|
||||
|
||||
// Protected article attachment routes (admin role only).
|
||||
attachments := router.Group("/admin/articles")
|
||||
attachments.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
||||
{
|
||||
attachments.POST("/attachments", handlers.UploadAttachment(db, cfg.Path))
|
||||
attachments.POST("/attachments/:id/delete", handlers.DeleteAttachment(db, cfg.Path))
|
||||
attachments.GET("/:id/attachments", handlers.ListAttachments(db))
|
||||
}
|
||||
|
||||
// Protected admin settings routes (platform configuration).
|
||||
settings := router.Group("/admin/settings")
|
||||
settings.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
||||
{
|
||||
settings.GET("/site", handlers.SiteSettingsPage(db))
|
||||
settings.POST("/site", handlers.SiteSettingsSave(db, cfg.Path))
|
||||
settings.GET("/navlinks", handlers.NavLinksSettingsPage(db))
|
||||
settings.POST("/navlinks", handlers.NavLinksSettingsSave(db))
|
||||
settings.GET("/upload", handlers.UploadSettingsPage(db))
|
||||
settings.POST("/upload", handlers.UploadSettingsSave(db))
|
||||
settings.GET("/download", handlers.DownloadSettingsPage(db))
|
||||
settings.POST("/download", handlers.DownloadSettingsSave(db))
|
||||
settings.GET("/comments", handlers.CommentSettingsPage(db))
|
||||
settings.POST("/comments", handlers.CommentSettingsSave(db))
|
||||
}
|
||||
|
||||
// Protected admin analytics routes (reading statistics).
|
||||
analytics := router.Group("/admin/analytics")
|
||||
analytics.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
||||
{
|
||||
analytics.GET("/views", handlers.ViewAnalyticsPage(db))
|
||||
}
|
||||
|
||||
// Protected profile routes.
|
||||
profile := router.Group("/profile")
|
||||
profile.Use(middleware.AuthRequired())
|
||||
{
|
||||
profile.GET("", handlers.ProfilePage(db))
|
||||
profile.POST("", handlers.UpdateProfile(db, cfg.Path))
|
||||
profile.POST("/avatar", handlers.UploadAvatar(db, cfg.Path))
|
||||
}
|
||||
|
||||
// Protected user article management routes (for non-admin users).
|
||||
myArticles := router.Group("/my")
|
||||
myArticles.Use(middleware.AuthRequired())
|
||||
{
|
||||
myArticles.GET("/articles", handlers.MyArticlesPage(db))
|
||||
myArticles.GET("/articles/new", handlers.MyArticleCreatePage(db))
|
||||
myArticles.POST("/articles/new", handlers.MyArticleCreate(db))
|
||||
myArticles.GET("/articles/:id/edit", handlers.MyArticleEditPage(db))
|
||||
myArticles.POST("/articles/:id/edit", handlers.MyArticleUpdate(db))
|
||||
myArticles.POST("/articles/:id/delete", handlers.MyArticleDelete(db))
|
||||
}
|
||||
|
||||
// Protected article attachment routes for user articles.
|
||||
myAttachments := router.Group("/my/articles")
|
||||
myAttachments.Use(middleware.AuthRequired())
|
||||
{
|
||||
myAttachments.POST("/attachments", handlers.UploadAttachment(db, cfg.Path))
|
||||
myAttachments.POST("/attachments/:id/delete", handlers.DeleteAttachment(db, cfg.Path))
|
||||
myAttachments.GET("/:id/attachments", handlers.ListAttachments(db))
|
||||
}
|
||||
|
||||
// 9. Start the server.
|
||||
// 9. 启动服务器。
|
||||
webPort := cfg.Web.Port
|
||||
socketPath := cfg.Web.Socket
|
||||
usePort := webPort != "" && webPort != "0"
|
||||
@@ -192,7 +144,7 @@ func main() {
|
||||
|
||||
if useSocket {
|
||||
go func() {
|
||||
os.Remove(socketPath) // remove stale socket file if exists
|
||||
os.Remove(socketPath) // 移除遗留的 socket 文件(若存在)
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to listen on unix socket %s: %v", socketPath, err)
|
||||
@@ -204,6 +156,214 @@ func main() {
|
||||
}()
|
||||
}
|
||||
|
||||
// Block forever.
|
||||
// 永久阻塞。
|
||||
select {}
|
||||
}
|
||||
|
||||
// registerRoutes 注册全部业务路由。独立成函数便于测试:
|
||||
// 签名包含 db 与 loginLimiter,但注册阶段不会触碰它们(handler 是惰性工厂),
|
||||
// 因此冒烟测试可传 nil。
|
||||
func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLimiter *handlers.LoginRateLimiter) {
|
||||
// 注册/评论限流器(SECURITY_TODO #27/#28):固定窗口、进程内存、map 有界。
|
||||
// 单实例部署无需共享存储。
|
||||
registerLimiter := handlers.NewWindowLimiter(10, time.Hour)
|
||||
commentLimiter := handlers.NewWindowLimiter(5, time.Minute)
|
||||
|
||||
// 公开页面。
|
||||
router.GET("/", handlers.HomePage(db))
|
||||
router.GET("/search", handlers.SearchPage(db))
|
||||
router.GET("/rss", handlers.RSSFeed(db))
|
||||
router.GET("/feed", handlers.RSSFeed(db))
|
||||
router.GET("/login", handlers.LoginPage())
|
||||
router.GET("/register", handlers.RegisterPage(db))
|
||||
router.GET("/article/:slug", handlers.ArticleDetail(db))
|
||||
|
||||
// 公开 JSON API。
|
||||
api := router.Group("/api")
|
||||
{
|
||||
api.GET("/articles", handlers.HomeArticlesAPI(db))
|
||||
api.POST("/auth/login", handlers.Login(db, loginLimiter))
|
||||
api.POST("/auth/register", handlers.Register(db, registerLimiter))
|
||||
api.POST("/auth/logout", handlers.Logout())
|
||||
api.POST("/article/:slug/comments", handlers.PostComment(db, commentLimiter))
|
||||
}
|
||||
|
||||
// 受保护的后台路由(仅管理员角色)。
|
||||
admin := router.Group("/admin")
|
||||
admin.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
admin.GET("", handlers.AdminDashboard(db))
|
||||
admin.GET("/articles", handlers.ArticleListPage(db))
|
||||
admin.GET("/articles/new", handlers.ArticleCreatePage(db))
|
||||
admin.GET("/articles/:id/edit", handlers.ArticleEditPage(db))
|
||||
}
|
||||
|
||||
adminArticleAPI := router.Group("/api/admin/articles")
|
||||
adminArticleAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
adminArticleAPI.POST("", handlers.ArticleCreate(db, "/admin"))
|
||||
adminArticleAPI.PUT("/:id", handlers.ArticleUpdate(db, "/admin/articles"))
|
||||
adminArticleAPI.DELETE("/:id", handlers.ArticleDelete(db, "/admin/articles"))
|
||||
}
|
||||
|
||||
// 受保护的后台评论管理路由(仅管理员角色)。
|
||||
comments := router.Group("/admin/comments")
|
||||
comments.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
comments.GET("", handlers.CommentListPage(db))
|
||||
}
|
||||
|
||||
commentsAPI := router.Group("/api/admin/comments")
|
||||
commentsAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
commentsAPI.POST("/:id/approve", handlers.CommentApprove(db))
|
||||
commentsAPI.POST("/:id/reject", handlers.CommentReject(db))
|
||||
commentsAPI.POST("/:id/delete", handlers.CommentDelete(db))
|
||||
}
|
||||
|
||||
// 受保护的后台用户管理路由(仅管理员角色)。
|
||||
users := router.Group("/admin/users")
|
||||
users.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
users.GET("", handlers.UserListPage(db))
|
||||
users.GET("/new", handlers.UserCreatePage(db))
|
||||
users.GET("/:id/edit", handlers.UserEditPage(db))
|
||||
}
|
||||
|
||||
usersAPI := router.Group("/api/admin/users")
|
||||
usersAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
usersAPI.POST("", handlers.UserCreate(db))
|
||||
usersAPI.PUT("/:id", handlers.UserUpdate(db))
|
||||
usersAPI.DELETE("/:id", handlers.UserDelete(db))
|
||||
}
|
||||
|
||||
// 受保护的后台文章附件 API / 路由(仅管理员角色)。
|
||||
// 注意 /api/admin/articles/attachments 的静态段与 /:id 参数段共存,
|
||||
// gin 对静态段优先,无冲突(由 main_test.go 冒烟测试验证)。
|
||||
adminAPI := router.Group("/api/admin")
|
||||
adminAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
adminAPI.POST("/articles/attachments", handlers.UploadAttachment(db, cfg.Path))
|
||||
adminAPI.DELETE("/articles/attachments/:id", handlers.DeleteAttachment(db, cfg.Path))
|
||||
adminAPI.GET("/articles/:id/attachments", handlers.ListAttachments(db))
|
||||
}
|
||||
|
||||
// 受保护的后台设置路由(平台配置)。
|
||||
settings := router.Group("/admin/settings")
|
||||
settings.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
settings.GET("/site", handlers.SiteSettingsPage(db))
|
||||
settings.GET("/navlinks", handlers.NavLinksSettingsPage(db))
|
||||
settings.GET("/upload", handlers.UploadSettingsPage(db))
|
||||
settings.GET("/download", handlers.DownloadSettingsPage(db))
|
||||
settings.GET("/comments", handlers.CommentSettingsPage(db))
|
||||
}
|
||||
|
||||
settingsAPI := router.Group("/api/admin/settings")
|
||||
settingsAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
settingsAPI.POST("/site", handlers.SiteSettingsSave(db, cfg.Path))
|
||||
settingsAPI.POST("/site/favicon", handlers.SiteFaviconUpload(db, cfg.Path))
|
||||
settingsAPI.POST("/site/logo", handlers.SiteLogoUpload(db, cfg.Path))
|
||||
settingsAPI.POST("/navlinks", handlers.NavLinksSettingsSave(db))
|
||||
settingsAPI.POST("/upload", handlers.UploadSettingsSave(db))
|
||||
settingsAPI.POST("/download", handlers.DownloadSettingsSave(db))
|
||||
settingsAPI.POST("/comments", handlers.CommentSettingsSave(db))
|
||||
}
|
||||
|
||||
// 受保护的后台统计路由(读取统计信息)。
|
||||
analytics := router.Group("/admin/analytics")
|
||||
analytics.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
analytics.GET("/views", handlers.ViewAnalyticsPage(db))
|
||||
}
|
||||
|
||||
// 受保护的个人资料路由。
|
||||
profile := router.Group("/profile")
|
||||
profile.Use(middleware.AuthRequired(db))
|
||||
{
|
||||
profile.GET("", handlers.ProfilePage(db))
|
||||
}
|
||||
|
||||
profileAPI := router.Group("/api/profile")
|
||||
profileAPI.Use(middleware.AuthRequired(db))
|
||||
{
|
||||
profileAPI.POST("", handlers.UpdateProfile(db, cfg.Path))
|
||||
profileAPI.POST("/avatar", handlers.UploadAvatar(db, cfg.Path))
|
||||
}
|
||||
|
||||
// 受保护的用户文章管理路由(面向非管理员用户)。
|
||||
myArticles := router.Group("/my")
|
||||
myArticles.Use(middleware.AuthRequired(db))
|
||||
{
|
||||
myArticles.GET("/articles", handlers.MyArticlesPage(db))
|
||||
myArticles.GET("/articles/new", handlers.MyArticleCreatePage(db))
|
||||
myArticles.GET("/articles/:id/edit", handlers.MyArticleEditPage(db))
|
||||
}
|
||||
|
||||
// 用户文章的受保护 API(仅登录用户,含 attachments 静态段与 :id 参数段)。
|
||||
myAPI := router.Group("/api/my/articles")
|
||||
myAPI.Use(middleware.AuthRequired(db))
|
||||
{
|
||||
myAPI.POST("", handlers.MyArticleCreate(db))
|
||||
myAPI.PUT("/:id", handlers.MyArticleUpdate(db))
|
||||
myAPI.DELETE("/:id", handlers.MyArticleDelete(db))
|
||||
myAPI.POST("/attachments", handlers.UploadAttachment(db, cfg.Path))
|
||||
myAPI.DELETE("/attachments/:id", handlers.DeleteAttachment(db, cfg.Path))
|
||||
myAPI.GET("/:id/attachments", handlers.ListAttachments(db))
|
||||
}
|
||||
}
|
||||
|
||||
// registerUploadRoutes 在 /uploads 组下暴露公开的上传子目录:avatars、
|
||||
// logos,以及配置的附件存储目录(外加向后兼容的默认 "attachments")。
|
||||
// 存储根目录绝不挂载——其中还包含 SQLite 数据库文件,
|
||||
// 该文件不可被下载(SECURITY_TODO #18)。
|
||||
// 禁用目录列表:仅具体文件可解析。
|
||||
func registerUploadRoutes(g *gin.RouterGroup, storagePath, storageDir string) {
|
||||
dirs := []string{"attachments", "avatars", "logos"}
|
||||
if dir := safeStorageDir(storageDir); dir != "attachments" && dir != "avatars" && dir != "logos" {
|
||||
dirs = append(dirs, dir)
|
||||
}
|
||||
for _, d := range dirs {
|
||||
h := serveUploadDir(filepath.Join(storagePath, d))
|
||||
g.GET("/"+d+"/*file", h)
|
||||
g.HEAD("/"+d+"/*file", h)
|
||||
}
|
||||
}
|
||||
|
||||
// safeStorageDir 将配置的附件存储目录收窄为安全的相对路径:
|
||||
// 非空、非绝对路径,且不含 ".." 或 "\"。
|
||||
// 任何不安全值回退到默认的 "attachments",
|
||||
// 使配置错误的 storage_dir 无法逃逸出存储根目录
|
||||
// (针对 SECURITY_TODO #22 的纵深防御)。
|
||||
func safeStorageDir(dir string) string {
|
||||
const fallback = "attachments"
|
||||
if dir == "" {
|
||||
return fallback
|
||||
}
|
||||
cleaned := path.Clean(dir)
|
||||
if path.IsAbs(cleaned) || cleaned == "." ||
|
||||
strings.Contains(cleaned, "..") || strings.Contains(cleaned, "\\") {
|
||||
return fallback
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// serveUploadDir 从一个上传子目录提供具体文件。
|
||||
// 目录列表和路径穿越尝试以 404 拒绝。
|
||||
func serveUploadDir(root string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
rel := c.Param("file") // 始终以 "/" 开头
|
||||
if strings.Contains(rel, "..") || strings.ContainsRune(rel, '\\') {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
full := filepath.Join(root, rel)
|
||||
if st, err := os.Stat(full); err != nil || st.IsDir() {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.File(full)
|
||||
}
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"go_blog/config"
|
||||
"go_blog/handlers"
|
||||
)
|
||||
|
||||
// newUploadsRouter 在模拟真实布局的临时存储根目录上构建
|
||||
// 使用生产上传路由的路由器:SQLite 数据库文件位于根目录中,
|
||||
// 上传文件位于子目录中。
|
||||
func newUploadsRouter(t *testing.T, storageDir string) (*gin.Engine, string) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "blog.db"), []byte("fake sqlite"), 0644); err != nil {
|
||||
t.Fatalf("seed blog.db: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
registerUploadRoutes(r.Group("/uploads"), root, storageDir)
|
||||
return r, root
|
||||
}
|
||||
|
||||
func doGet(t *testing.T, r *gin.Engine, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
return w
|
||||
}
|
||||
|
||||
func seedUploadFile(t *testing.T, root, sub, name string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, sub)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", dir, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("content"), 0644); err != nil {
|
||||
t.Fatalf("seed %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadsWhitelistHidesStorageRoot(t *testing.T) {
|
||||
r, root := newUploadsRouter(t, "")
|
||||
for _, sub := range []string{"attachments", "avatars", "logos"} {
|
||||
seedUploadFile(t, root, sub, "file.txt")
|
||||
}
|
||||
|
||||
// 存储根目录中的数据库文件必须不可下载。
|
||||
if w := doGet(t, r, "/uploads/blog.db"); w.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET /uploads/blog.db = %d, want 404 (database leak)", w.Code)
|
||||
}
|
||||
|
||||
// 任何地方都不允许目录列表。
|
||||
for _, p := range []string{
|
||||
"/uploads", "/uploads/",
|
||||
"/uploads/attachments/", "/uploads/avatars/", "/uploads/logos/",
|
||||
} {
|
||||
if w := doGet(t, r, p); w.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET %s = %d, want 404 (no directory listing)", p, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 路径穿越尝试不得逃逸出子目录。
|
||||
for _, p := range []string{
|
||||
"/uploads/attachments/../blog.db",
|
||||
"/uploads/attachments/..%2f..%2fblog.db",
|
||||
"/uploads/attachments/%2e%2e/blog.db",
|
||||
} {
|
||||
if w := doGet(t, r, p); w.Code == http.StatusOK {
|
||||
t.Fatalf("GET %s = %d, want non-200 (traversal)", p, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 白名单子目录中的文件仍然可以访问。
|
||||
for _, p := range []string{
|
||||
"/uploads/attachments/file.txt",
|
||||
"/uploads/avatars/file.txt",
|
||||
"/uploads/logos/file.txt",
|
||||
} {
|
||||
if w := doGet(t, r, p); w.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s = %d, want 200", p, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadsWhitelistCustomStorageDir(t *testing.T) {
|
||||
r, root := newUploadsRouter(t, "files")
|
||||
seedUploadFile(t, root, "files", "a.bin")
|
||||
|
||||
if w := doGet(t, r, "/uploads/files/a.bin"); w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /uploads/files/a.bin = %d, want 200", w.Code)
|
||||
}
|
||||
// 默认目录保持挂载以向后兼容。
|
||||
seedUploadFile(t, root, "attachments", "old.txt")
|
||||
if w := doGet(t, r, "/uploads/attachments/old.txt"); w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /uploads/attachments/old.txt = %d, want 200", w.Code)
|
||||
}
|
||||
if w := doGet(t, r, "/uploads/blog.db"); w.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET /uploads/blog.db = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadsWhitelistUnsafeStorageDirFallsBack(t *testing.T) {
|
||||
for _, dir := range []string{"../evil", "/etc", "..", "a/../../b", "."} {
|
||||
r, root := newUploadsRouter(t, dir)
|
||||
seedUploadFile(t, root, "attachments", "file.txt")
|
||||
if w := doGet(t, r, "/uploads/attachments/file.txt"); w.Code != http.StatusOK {
|
||||
t.Fatalf("storage dir %q: fallback mount broken: %d", dir, w.Code)
|
||||
}
|
||||
if w := doGet(t, r, "/uploads/blog.db"); w.Code != http.StatusNotFound {
|
||||
t.Fatalf("storage dir %q: /uploads/blog.db = %d, want 404", dir, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadsWhitelistStorageDirDedup(t *testing.T) {
|
||||
// 与已知目录相同的存储目录不能因重复路由而 panic。
|
||||
r, root := newUploadsRouter(t, "avatars")
|
||||
seedUploadFile(t, root, "avatars", "me.jpg")
|
||||
if w := doGet(t, r, "/uploads/avatars/me.jpg"); w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /uploads/avatars/me.jpg = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterRoutesSmoke 通过完整的路由注册冒烟测试:
|
||||
// 1. 路由冲突(静态段 attachment 与 :id 参数段共存)会在此处 panic;
|
||||
// 2. 断言 /api 搬移端点已在正确的 HTTP 方法下注册。
|
||||
func TestRegisterRoutesSmoke(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
registerRoutes(r, &config.Config{}, nil, handlers.NewLoginLimiter())
|
||||
|
||||
want := map[string]string{
|
||||
// 既有 JSON API。
|
||||
"GET /api/articles": "",
|
||||
// 认证 API。
|
||||
"POST /api/auth/login": "",
|
||||
"POST /api/auth/register": "",
|
||||
"POST /api/auth/logout": "",
|
||||
// 评论 API。
|
||||
"POST /api/article/:slug/comments": "",
|
||||
"POST /api/admin/comments/:id/approve": "",
|
||||
"POST /api/admin/comments/:id/reject": "",
|
||||
"POST /api/admin/comments/:id/delete": "",
|
||||
// 文章 CRUD API。
|
||||
"POST /api/admin/articles": "",
|
||||
"PUT /api/admin/articles/:id": "",
|
||||
"DELETE /api/admin/articles/:id": "",
|
||||
"POST /api/my/articles": "",
|
||||
"PUT /api/my/articles/:id": "",
|
||||
"DELETE /api/my/articles/:id": "",
|
||||
// 用户 CRUD API。
|
||||
"POST /api/admin/users": "",
|
||||
"PUT /api/admin/users/:id": "",
|
||||
"DELETE /api/admin/users/:id": "",
|
||||
// 设置 API。
|
||||
"POST /api/admin/settings/site": "",
|
||||
"POST /api/admin/settings/site/favicon": "",
|
||||
"POST /api/admin/settings/site/logo": "",
|
||||
"POST /api/admin/settings/navlinks": "",
|
||||
"POST /api/admin/settings/upload": "",
|
||||
"POST /api/admin/settings/download": "",
|
||||
"POST /api/admin/settings/comments": "",
|
||||
// 搬移的附件/头像端点。
|
||||
"POST /api/admin/articles/attachments": "",
|
||||
"DELETE /api/admin/articles/attachments/:id": "",
|
||||
"GET /api/admin/articles/:id/attachments": "",
|
||||
"POST /api/profile/avatar": "",
|
||||
"POST /api/profile": "",
|
||||
"POST /api/my/articles/attachments": "",
|
||||
"DELETE /api/my/articles/attachments/:id": "",
|
||||
"GET /api/my/articles/:id/attachments": "",
|
||||
}
|
||||
for route := range want {
|
||||
found := false
|
||||
for _, rt := range r.Routes() {
|
||||
if rt.Method+" "+rt.Path == route {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("route %s not registered", route)
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
-28
@@ -2,22 +2,102 @@ package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go_blog/buildinfo"
|
||||
"go_blog/i18n"
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// AuthRequired is middleware that protects routes. If the user is not logged in,
|
||||
// they are redirected to /login.
|
||||
func AuthRequired() gin.HandlerFunc {
|
||||
// isAPIRequest 报告请求是否命中 /api 前缀的 JSON 接口。
|
||||
// 认证失败时 API 返回 JSON 错误,页面则保持 302 重定向。
|
||||
func isAPIRequest(c *gin.Context) bool {
|
||||
return strings.HasPrefix(c.Request.URL.Path, "/api")
|
||||
}
|
||||
|
||||
// apiError 以 API 错误格式终止请求(401 未认证 / 403 无权限 / 413 请求体
|
||||
// 过大等)。文案按请求语言翻译(SetUserContext 已在全局中间件中注入 tr)。
|
||||
func apiError(c *gin.Context, status int, trKey string) {
|
||||
tr, _ := c.Get("tr")
|
||||
m, _ := tr.(map[string]string)
|
||||
code := trKey
|
||||
msg := m[code]
|
||||
if msg == "" {
|
||||
code = "api_error"
|
||||
msg = m["api_error"]
|
||||
}
|
||||
c.AbortWithStatusJSON(status, gin.H{
|
||||
"ok": false,
|
||||
"code": code,
|
||||
"error": msg,
|
||||
})
|
||||
}
|
||||
|
||||
// sessionUserID 从会话中提取已登录用户的数值 ID,
|
||||
// 兼容 int/uint/int64/float64 的存储类型。若不存在或类型不符,ok=false。
|
||||
func sessionUserID(session sessions.Session) (uint, bool) {
|
||||
userID := session.Get("user_id")
|
||||
if userID == nil {
|
||||
return 0, false
|
||||
}
|
||||
switch v := userID.(type) {
|
||||
case uint:
|
||||
return v, true
|
||||
case int:
|
||||
return uint(v), true
|
||||
case int64:
|
||||
return uint(v), true
|
||||
case float64:
|
||||
return uint(v), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// clearUserSession 从会话中清除认证状态,仅保留无害的 UI 偏好
|
||||
// (语言与 CSRF 令牌,与登录处理器的轮换逻辑保持一致),
|
||||
// 以确保其他标签页中已渲染的表单仍然有效。
|
||||
func clearUserSession(session sessions.Session) {
|
||||
lang, _ := session.Get("lang").(string)
|
||||
csrfTok, _ := session.Get(CSRFSessionKey).(string)
|
||||
session.Clear()
|
||||
if lang != "" {
|
||||
session.Set("lang", lang)
|
||||
}
|
||||
if csrfTok != "" {
|
||||
session.Set(CSRFSessionKey, csrfTok)
|
||||
}
|
||||
session.Save()
|
||||
}
|
||||
|
||||
// AuthRequired 是保护路由的中间件。若用户未登录,则重定向到 /login。
|
||||
// 会话用户还会在每次请求时重新对数据库校验:已停用、已锁定或已软删除的
|
||||
// 账户会立即失去访问权限,而无需等到 Cookie 过期(SECURITY_TODO #20)。
|
||||
func AuthRequired(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
if userID == nil {
|
||||
uid, ok := sessionUserID(session)
|
||||
if !ok {
|
||||
if isAPIRequest(c) {
|
||||
apiError(c, http.StatusUnauthorized, "api_unauthorized")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
var user models.User
|
||||
if err := db.First(&user, uid).Error; err != nil || user.Status != models.StatusNormal {
|
||||
// 账户已不可用——销毁会话,防止过期 Cookie 被重放。
|
||||
clearUserSession(session)
|
||||
if isAPIRequest(c) {
|
||||
apiError(c, http.StatusUnauthorized, "api_unauthorized")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
@@ -26,20 +106,28 @@ func AuthRequired() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// AdminRequired is middleware that restricts a route to admin-role users. It
|
||||
// must run after AuthRequired (which guarantees a session user exists). Non-admin
|
||||
// users are redirected back to the admin dashboard.
|
||||
// AdminRequired 是仅允许管理员角色用户访问路由的中间件。它必须在
|
||||
// AuthRequired 之后运行(后者保证会话用户存在且状态正常)。
|
||||
// 非管理员用户会被重定向回管理后台。
|
||||
func AdminRequired(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
if userID == nil {
|
||||
uid, ok := sessionUserID(session)
|
||||
if !ok {
|
||||
if isAPIRequest(c) {
|
||||
apiError(c, http.StatusUnauthorized, "api_unauthorized")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err != nil || user.Role != models.RoleAdmin {
|
||||
if err := db.First(&user, uid).Error; err != nil || user.Role != models.RoleAdmin {
|
||||
if isAPIRequest(c) {
|
||||
apiError(c, http.StatusForbidden, "api_forbidden")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin")
|
||||
c.Abort()
|
||||
return
|
||||
@@ -48,14 +136,14 @@ func AdminRequired(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// SetUserContext is global middleware that reads the session and sets
|
||||
// template-friendly context values for all pages (language, auth state, etc.).
|
||||
// SetUserContext 是全局中间件,读取会话并为所有页面设置
|
||||
// 便于模板使用的上下文值(语言、认证状态等)。
|
||||
func SetUserContext(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
|
||||
// --- Language detection ---
|
||||
// Priority: query param > session > Accept-Language header > default EN
|
||||
// --- 语言检测 ---
|
||||
// 优先级:查询参数 > 会话 > Accept-Language 请求头 > 默认 EN
|
||||
var lang i18n.Lang
|
||||
queryLang := c.Query("lang")
|
||||
|
||||
@@ -65,46 +153,48 @@ func SetUserContext(db *gorm.DB) gin.HandlerFunc {
|
||||
case "en":
|
||||
lang = i18n.EN
|
||||
case "":
|
||||
// Try session
|
||||
// 尝试从会话中读取
|
||||
if saved, ok := session.Get("lang").(string); ok {
|
||||
lang = i18n.Lang(saved)
|
||||
}
|
||||
if lang == "" {
|
||||
// Try Accept-Language header
|
||||
// 尝试从 Accept-Language 请求头检测
|
||||
lang = i18n.DetectLang(c.GetHeader("Accept-Language"))
|
||||
}
|
||||
default:
|
||||
// Unsupported language in query — fall back to English.
|
||||
// 查询参数包含不支持的语言——回退到英语。
|
||||
lang = i18n.EN
|
||||
}
|
||||
|
||||
// Persist language in session.
|
||||
// 将会话中的语言持久化。
|
||||
session.Set("lang", string(lang))
|
||||
session.Save()
|
||||
|
||||
// Make translations available in the Gin context.
|
||||
// 将翻译字典放入 Gin 上下文。
|
||||
c.Set("tr", i18n.T(lang))
|
||||
c.Set("lang", string(lang))
|
||||
|
||||
// Set the opposite language code for the language switcher link.
|
||||
// 为语言切换链接设置相反的语言代码。
|
||||
switchLang := "zh"
|
||||
if lang == i18n.ZH {
|
||||
switchLang = "en"
|
||||
}
|
||||
c.Set("switch_lang", switchLang)
|
||||
|
||||
// --- Auth state ---
|
||||
userID := session.Get("user_id")
|
||||
// --- 认证状态 ---
|
||||
// 仅当账户仍然存在且状态正常时,才认为用户已登录:
|
||||
// 被停用/锁定/软删除的账户,在其会话失效后
|
||||
// 不得继续保留模板级权限(如评论自动通过)(SECURITY_TODO #20)。
|
||||
isLoggedIn := false
|
||||
var username string
|
||||
var avatar string
|
||||
var displayName string
|
||||
var role string
|
||||
|
||||
if userID != nil {
|
||||
isLoggedIn = true
|
||||
if uid, ok := sessionUserID(session); ok {
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err == nil {
|
||||
if err := db.First(&user, uid).Error; err == nil && user.Status == models.StatusNormal {
|
||||
isLoggedIn = true
|
||||
username = user.Username
|
||||
avatar = user.Avatar
|
||||
displayName = user.DisplayName
|
||||
@@ -118,7 +208,7 @@ func SetUserContext(db *gorm.DB) gin.HandlerFunc {
|
||||
c.Set("display_name", displayName)
|
||||
c.Set("role", role)
|
||||
|
||||
// --- Site platform configuration (from DB cache) ---
|
||||
// --- 站点平台配置(来自数据库缓存)---
|
||||
site := models.GetSiteSetting()
|
||||
c.Set("site_setting", site)
|
||||
c.Set("site_logo", site.Logo)
|
||||
@@ -131,10 +221,13 @@ func SetUserContext(db *gorm.DB) gin.HandlerFunc {
|
||||
c.Set("site_home_subtitle", site.HomeSubtitle(string(lang)))
|
||||
c.Set("site_footer_text", site.FooterText(string(lang)))
|
||||
|
||||
// --- Navigation links (from DB cache) ---
|
||||
// --- 导航链接(来自数据库缓存)---
|
||||
navLinks := models.GetNavLinks()
|
||||
c.Set("nav_links", navLinks)
|
||||
|
||||
// --- 构建信息(编译时注入的 Git 版本与编译时间)---
|
||||
c.Set("build_info", buildinfo.String())
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// jsonBodyLimit 是非 multipart 请求体(JSON / 表单 / 未知类型)的统一上限
|
||||
// (SECURITY_TODO #26)。4 MiB 覆盖最大的合法载荷——文章 Markdown 正文;
|
||||
// 登录/注册/评论等小载荷共用同一上限,避免按端点维护多套配置。
|
||||
const jsonBodyLimit int64 = 4 << 20
|
||||
|
||||
// multipartOverhead 叠加在上传策略派生的大小之上,容纳 multipart 编码
|
||||
// 开销(边界、_csrf/session_token 等表单字段)。
|
||||
const multipartOverhead int64 = 1 << 20
|
||||
|
||||
// BodyLimit 限制不安全方法(POST/PUT/PATCH/DELETE)的请求体大小,
|
||||
// 防止未认证的内存/磁盘耗尽 DoS(SECURITY_TODO #26):
|
||||
//
|
||||
// - Content-Length 已知且超限时立即返回 413,不读取请求体;
|
||||
// - 其余请求体经 http.MaxBytesReader 封装:超限后读取立即失败,
|
||||
// JSON 路径由 handlers.bindJSON 识别并转为 413;multipart 路径的
|
||||
// 解析在 CSRF/处理器中进行,同样在限额处截断。
|
||||
//
|
||||
// 必须注册在 CSRFProtect 之前——CSRF 中间件解析 multipart 表单
|
||||
// (查找 _csrf 字段)会读取整个请求体;必须在 SetUserContext 之后,
|
||||
// 使 413 文案可按请求语言翻译(apiError 读取上下文中的 tr)。
|
||||
func BodyLimit() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
switch c.Request.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if c.Request.Body == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
limit := bodyLimitFor(c.Request.Header.Get("Content-Type"))
|
||||
if c.Request.ContentLength > limit {
|
||||
apiError(c, http.StatusRequestEntityTooLarge, "request_too_large")
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// bodyLimitFor 依据 Content-Type 返回请求体上限:
|
||||
// multipart 按平台上传策略派生,其余(JSON / urlencoded / 未知)统一
|
||||
// 走 JSON 上限。urlencoded 本身已被 net/http 限制在 1 MiB 内
|
||||
// (parsePostForm),这里再统一封顶一层。
|
||||
func bodyLimitFor(contentType string) int64 {
|
||||
ct := strings.ToLower(strings.TrimSpace(contentType))
|
||||
if strings.HasPrefix(ct, "multipart/form-data") {
|
||||
return maxUploadBodyLimit() + multipartOverhead
|
||||
}
|
||||
return jsonBodyLimit
|
||||
}
|
||||
|
||||
// maxUploadBodyLimit 返回当前平台策略下可被接受的最大单文件大小:
|
||||
// 全局默认值与各启用类型的按类型限制取最大值,且不低于编译期默认,
|
||||
// 防止异常的零值配置把上限压垮到小于合法上传。
|
||||
func maxUploadBodyLimit() int64 {
|
||||
cfg := models.GetUploadConfig()
|
||||
max := cfg.DefaultMaxSize
|
||||
if max < models.DefaultUploadMaxSize {
|
||||
max = models.DefaultUploadMaxSize
|
||||
}
|
||||
types := models.GetUploadFileTypes()
|
||||
for i := range types {
|
||||
if !types[i].Enabled {
|
||||
continue
|
||||
}
|
||||
if s := types[i].EffectiveMaxSize(cfg.DefaultMaxSize); s > max {
|
||||
max = s
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// newClientIPRouter 模拟生产环境的可信代理配置:
|
||||
// 仅信任回环地址(即 Caddy/nginx 主机)。
|
||||
func newClientIPRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
if err := r.SetTrustedProxies([]string{"127.0.0.1", "::1"}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
r.GET("/ip", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, c.ClientIP())
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func TestClientIPSpoofingBlocked(t *testing.T) {
|
||||
r := newClientIPRouter()
|
||||
|
||||
// 直接(不受信任)客户端伪造 X-Forwarded-For 时,
|
||||
// 不得改变记录的 IP。
|
||||
req := httptest.NewRequest(http.MethodGet, "/ip", nil)
|
||||
req.RemoteAddr = "203.0.113.5:12345"
|
||||
req.Header.Set("X-Forwarded-For", "6.6.6.6")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if got := w.Body.String(); got != "203.0.113.5" {
|
||||
t.Errorf("direct client with forged XFF: got %q, want 203.0.113.5", got)
|
||||
}
|
||||
|
||||
// 可信代理(回环)转发真实链路:最右侧不受信任的条目生效,
|
||||
// 更早的(客户端提供的)条目被忽略。
|
||||
req = httptest.NewRequest(http.MethodGet, "/ip", nil)
|
||||
req.RemoteAddr = "127.0.0.1:54321"
|
||||
req.Header.Set("X-Forwarded-For", "6.6.6.6, 198.51.100.42")
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if got := w.Body.String(); got != "198.51.100.42" {
|
||||
t.Errorf("proxy-forwarded chain: got %q, want 198.51.100.42 (client-supplied entry must be ignored)", got)
|
||||
}
|
||||
|
||||
// 可信代理转发单个条目:该条目即为客户端。
|
||||
req = httptest.NewRequest(http.MethodGet, "/ip", nil)
|
||||
req.RemoteAddr = "127.0.0.1:54321"
|
||||
req.Header.Set("X-Forwarded-For", "198.51.100.42")
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if got := w.Body.String(); got != "198.51.100.42" {
|
||||
t.Errorf("proxy-forwarded single entry: got %q, want 198.51.100.42", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CSRF 防护在现有会话存储之上采用同步令牌(synchronizer-token)模式:
|
||||
// - 安全方法(GET/HEAD/OPTIONS):首次使用时按会话生成令牌,
|
||||
// 并通过模板 / JS 暴露,以便嵌入表单。
|
||||
// - 不安全方法(POST/PUT/PATCH/DELETE):请求必须携带令牌,
|
||||
// 可以是 "_csrf" 表单字段(普通表单、multipart 上传),
|
||||
// 也可以是 "X-CSRF-Token" 请求头(AJAX)。不匹配时以 403 终止请求。
|
||||
//
|
||||
// 令牌与会话绑定,因此既适用于匿名访客(如评论表单),
|
||||
// 也适用于已登录用户。
|
||||
|
||||
const (
|
||||
// CSRFFieldName 是携带令牌的表单字段名。
|
||||
CSRFFieldName = "_csrf"
|
||||
// CSRFHeaderName 是携带令牌的 HTTP 请求头名(AJAX)。
|
||||
CSRFHeaderName = "X-CSRF-Token"
|
||||
// CSRFSessionKey 是服务端存储令牌的会话键。
|
||||
CSRFSessionKey = "csrf_token"
|
||||
// CSRFContextKey 通过 c.Set 将令牌暴露给处理器/模板。
|
||||
CSRFContextKey = "csrf_token"
|
||||
)
|
||||
|
||||
// newCSRFToken 返回 256 位随机十六进制令牌。crypto/rand 失败不可恢复:
|
||||
// 宁可 panic,也不削弱防御。
|
||||
func newCSRFToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("csrf: failed to read random bytes: " + err.Error())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// csrfTokensEqual 以常量时间比较两个令牌。
|
||||
func csrfTokensEqual(a, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
var v byte
|
||||
for i := 0; i < len(a); i++ {
|
||||
v |= a[i] ^ b[i]
|
||||
}
|
||||
return v == 0
|
||||
}
|
||||
|
||||
// CSRFProtect 校验不安全请求是否携带与会话匹配的 CSRF 令牌。
|
||||
// 必须在 sessions 中间件之后注册。
|
||||
func CSRFProtect() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
token, _ := session.Get(CSRFSessionKey).(string)
|
||||
|
||||
switch c.Request.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
|
||||
// 安全方法:确保令牌存在,并交给模板层使用。
|
||||
if token == "" {
|
||||
token = newCSRFToken()
|
||||
session.Set(CSRFSessionKey, token)
|
||||
_ = session.Save()
|
||||
}
|
||||
c.Set(CSRFContextKey, token)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 不安全方法:要求携带匹配的令牌。
|
||||
supplied := c.PostForm(CSRFFieldName)
|
||||
if supplied == "" {
|
||||
supplied = c.GetHeader(CSRFHeaderName)
|
||||
}
|
||||
if token == "" || supplied == "" || !csrfTokensEqual(token, supplied) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.String(http.StatusForbidden, "403 Forbidden: CSRF token missing or invalid ("+strconv.Quote(c.Request.Method)+" "+c.Request.URL.Path+")")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(CSRFContextKey, token)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func newCSRFTestRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
store := cookie.NewStore([]byte("test-secret"))
|
||||
r.Use(sessions.Sessions("test_session", store))
|
||||
r.Use(CSRFProtect())
|
||||
|
||||
r.GET("/form", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "TOKEN="+c.GetString(CSRFContextKey))
|
||||
})
|
||||
r.HEAD("/form", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "TOKEN="+c.GetString(CSRFContextKey))
|
||||
})
|
||||
r.POST("/action", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// tokenFromForm 使用给定的会话 Cookie 执行 GET /form,并返回
|
||||
// 签发的 CSRF 令牌及(可能更新的)会话 Cookie。
|
||||
func tokenFromForm(t *testing.T, r *gin.Engine, sessionCookie string) (token, cookie string) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/form", nil)
|
||||
if sessionCookie != "" {
|
||||
req.Header.Set("Cookie", sessionCookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /form: status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
const prefix = "TOKEN="
|
||||
if !strings.HasPrefix(body, prefix) {
|
||||
t.Fatalf("GET /form: unexpected body %q", body)
|
||||
}
|
||||
token = strings.TrimPrefix(body, prefix)
|
||||
cookie = w.Header().Get("Set-Cookie")
|
||||
return token, cookie
|
||||
}
|
||||
|
||||
func postAction(r *gin.Engine, sessionCookie, token string, useHeader bool) *httptest.ResponseRecorder {
|
||||
form := url.Values{}
|
||||
if !useHeader {
|
||||
form.Set(CSRFFieldName, token)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/action", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if sessionCookie != "" {
|
||||
req.Header.Set("Cookie", sessionCookie)
|
||||
}
|
||||
if useHeader {
|
||||
req.Header.Set(CSRFHeaderName, token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestCSRFTokenIssuedOnGET(t *testing.T) {
|
||||
r := newCSRFTestRouter()
|
||||
token, cookie := tokenFromForm(t, r, "")
|
||||
if token == "" {
|
||||
t.Fatal("expected a token to be issued on GET")
|
||||
}
|
||||
if !strings.Contains(cookie, "test_session=") {
|
||||
t.Fatalf("expected session cookie to be set, got %q", cookie)
|
||||
}
|
||||
|
||||
// 相同会话的第二次 GET 必须返回相同的令牌。
|
||||
token2, _ := tokenFromForm(t, r, cookie)
|
||||
if token2 != token {
|
||||
t.Fatalf("token changed between requests: %q vs %q", token, token2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFPostRejectedWithoutToken(t *testing.T) {
|
||||
r := newCSRFTestRouter()
|
||||
_, cookie := tokenFromForm(t, r, "")
|
||||
|
||||
w := postAction(r, cookie, "", false)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("POST without token: status = %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFPostRejectedWithWrongToken(t *testing.T) {
|
||||
r := newCSRFTestRouter()
|
||||
_, cookie := tokenFromForm(t, r, "")
|
||||
|
||||
w := postAction(r, cookie, "bogus-token", false)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("POST with wrong token: status = %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFPostRejectedWithoutSession(t *testing.T) {
|
||||
r := newCSRFTestRouter()
|
||||
// 没有先前的 GET:无会话,也未签发令牌。
|
||||
w := postAction(r, "", "some-token", false)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("POST without session: status = %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFPostAcceptedWithFormField(t *testing.T) {
|
||||
r := newCSRFTestRouter()
|
||||
token, cookie := tokenFromForm(t, r, "")
|
||||
|
||||
w := postAction(r, cookie, token, false)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("POST with valid token: status = %d, want 200 (body: %s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFPostAcceptedWithHeader(t *testing.T) {
|
||||
r := newCSRFTestRouter()
|
||||
token, cookie := tokenFromForm(t, r, "")
|
||||
|
||||
w := postAction(r, cookie, token, true)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("POST with token in header: status = %d, want 200 (body: %s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFSafeMethodsPassWithoutToken(t *testing.T) {
|
||||
r := newCSRFTestRouter()
|
||||
// GET 和 HEAD 已注册路由;OPTIONS 未注册(gin 不会自动注册),
|
||||
// 因此会落入 noRoute——但在所有情况下,CSRF 中间件本身都不得以 403 拒绝。
|
||||
for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodOptions} {
|
||||
req := httptest.NewRequest(method, "/form", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code == http.StatusForbidden {
|
||||
t.Fatalf("%s /form: status = 403, CSRF middleware must not reject safe methods", method)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// IsHTTPSRequest 报告客户端是否通过 TLS 访问到我们。当应用部署在反向代理
|
||||
// (Caddy/nginx)之后,应用本身通常使用明文连接,因此还需参考
|
||||
// X-Forwarded-Proto 请求头。仅当请求由可信代理转发时才采信该头部——
|
||||
// 不受信任的客户端伪造它,最坏情况下只会破坏自己的会话(Cookie 变为
|
||||
// Secure,在明文 HTTP 下会被拒收)。
|
||||
func IsHTTPSRequest(c *gin.Context) bool {
|
||||
if c.Request.TLS != nil {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package middleware
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// csp 是 HTML 响应的 Content-Security-Policy 策略。
|
||||
//
|
||||
// 需要 'unsafe-inline' 是因为模板内嵌了 <script> 与 <style> 块
|
||||
// (这些块的 XSS 防御由 Go html/template 提供)。所有第三方资源均已
|
||||
// 本地化托管(SECURITY_TODO #9),因此策略不允许其他来源的脚本或样式。
|
||||
const csp = "default-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' data: https: http:; " +
|
||||
"font-src 'self' data: https:; " +
|
||||
"connect-src 'self'; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self'"
|
||||
|
||||
// SecurityHeaders 为每个响应设置安全加固头:
|
||||
// CSP、nosniff、点击劫持防护(X-Frame-Options + frame-ancestors)、
|
||||
// Referrer-Policy,以及当请求通过 HTTPS 到达时的 HSTS。
|
||||
// 必须在其他中间件之前注册,以确保即使在被拒绝(403/重定向)的响应上
|
||||
// 也包含这些头。
|
||||
func SecurityHeaders() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Content-Security-Policy", csp)
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("X-Frame-Options", "DENY")
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
if IsHTTPSRequest(c) {
|
||||
// 故意省略 includeSubDomains:站点的某些子域
|
||||
// 可能仍通过明文 HTTP 提供访问。
|
||||
c.Header("Strict-Transport-Security", "max-age=31536000")
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func newHeadersTestRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(SecurityHeaders())
|
||||
r.GET("/", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
|
||||
return r
|
||||
}
|
||||
|
||||
func TestSecurityHeadersPresent(t *testing.T) {
|
||||
r := newHeadersTestRouter()
|
||||
|
||||
// 明文 HTTP 请求:加固头存在,无 HSTS。
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
for _, h := range []string{
|
||||
"Content-Security-Policy",
|
||||
"X-Content-Type-Options",
|
||||
"X-Frame-Options",
|
||||
"Referrer-Policy",
|
||||
"Permissions-Policy",
|
||||
} {
|
||||
if v := w.Header().Get(h); v == "" {
|
||||
t.Errorf("missing header %s", h)
|
||||
}
|
||||
}
|
||||
if w.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Errorf("X-Content-Type-Options = %q, want nosniff", w.Header().Get("X-Content-Type-Options"))
|
||||
}
|
||||
if w.Header().Get("X-Frame-Options") != "DENY" {
|
||||
t.Errorf("X-Frame-Options = %q, want DENY", w.Header().Get("X-Frame-Options"))
|
||||
}
|
||||
if w.Header().Get("Content-Security-Policy") == "" {
|
||||
t.Error("CSP header missing")
|
||||
}
|
||||
if w.Header().Get("Strict-Transport-Security") != "" {
|
||||
t.Errorf("HSTS must be absent over plain HTTP, got %q", w.Header().Get("Strict-Transport-Security"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersHSTSOverHTTPS(t *testing.T) {
|
||||
r := newHeadersTestRouter()
|
||||
|
||||
// TLS 请求:HSTS 存在。
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if v := w.Header().Get("Strict-Transport-Security"); v != "max-age=31536000" {
|
||||
t.Errorf("HSTS over TLS = %q, want max-age=31536000", v)
|
||||
}
|
||||
|
||||
// 位于可信代理之后(X-Forwarded-Proto: https):HSTS 存在。
|
||||
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if v := w.Header().Get("Strict-Transport-Security"); v != "max-age=31536000" {
|
||||
t.Errorf("HSTS behind proxy = %q, want max-age=31536000", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHTTPSRequest(t *testing.T) {
|
||||
// TLS 请求。
|
||||
if !IsHTTPSRequest(&gin.Context{Request: mustTLSRequest()}) {
|
||||
t.Error("TLS request must be HTTPS")
|
||||
}
|
||||
// 明文请求。
|
||||
c := &gin.Context{}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if IsHTTPSRequest(c) {
|
||||
t.Error("plain request must not be HTTPS")
|
||||
}
|
||||
// X-Forwarded-Proto: https。
|
||||
c.Request.Header.Set("X-Forwarded-Proto", "https")
|
||||
if !IsHTTPSRequest(c) {
|
||||
t.Error("X-Forwarded-Proto https must be treated as HTTPS")
|
||||
}
|
||||
// X-Forwarded-Proto: http 不得触发 HTTPS 行为。
|
||||
c.Request.Header.Set("X-Forwarded-Proto", "http")
|
||||
if IsHTTPSRequest(c) {
|
||||
t.Error("X-Forwarded-Proto http must not be treated as HTTPS")
|
||||
}
|
||||
}
|
||||
|
||||
func mustTLSRequest() *http.Request {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.TLS = &tls.ConnectionState{}
|
||||
return r
|
||||
}
|
||||
+19
-19
@@ -6,34 +6,34 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Article status constants.
|
||||
// 文章状态常量。
|
||||
const (
|
||||
ArticleDraft = 0 // 草稿
|
||||
ArticlePublished = 1 // 已发布
|
||||
ArticleArchived = 2 // 已归档
|
||||
)
|
||||
|
||||
// Article represents a blog post.
|
||||
// Article 表示一篇博客文章。
|
||||
type Article struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"uniqueIndex:idx_slug_deleted_at" json:"deleted_at"`
|
||||
AuthorID uint `gorm:"not null;index" json:"author_id"`
|
||||
Title string `gorm:"not null;size:255" json:"title"`
|
||||
Summary string `gorm:"size:512" json:"summary"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Cover string `gorm:"size:512" json:"cover"`
|
||||
Status int `gorm:"default:0;index" json:"status"`
|
||||
IsTop bool `gorm:"default:false" json:"is_top"`
|
||||
ViewCount int `gorm:"default:0" json:"view_count"`
|
||||
Slug string `gorm:"uniqueIndex:idx_slug_deleted_at;size:255" json:"slug"`
|
||||
PublishedAt *time.Time `gorm:"index" json:"published_at"`
|
||||
Author User `gorm:"foreignKey:AuthorID" json:"-"`
|
||||
Tags []Tag `gorm:"many2many:article_tags;" json:"tags"`
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"uniqueIndex:idx_slug_deleted_at" json:"deleted_at"`
|
||||
AuthorID uint `gorm:"not null;index" json:"author_id"`
|
||||
Title string `gorm:"not null;size:255" json:"title"`
|
||||
Summary string `gorm:"size:512" json:"summary"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Cover string `gorm:"size:512" json:"cover"`
|
||||
Status int `gorm:"default:0;index" json:"status"`
|
||||
IsTop bool `gorm:"default:false" json:"is_top"`
|
||||
ViewCount int `gorm:"default:0" json:"view_count"`
|
||||
Slug string `gorm:"uniqueIndex:idx_slug_deleted_at;size:255" json:"slug"`
|
||||
PublishedAt *time.Time `gorm:"index" json:"published_at"`
|
||||
Author User `gorm:"foreignKey:AuthorID" json:"-"`
|
||||
Tags []Tag `gorm:"many2many:article_tags;" json:"tags"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (Article) TableName() string {
|
||||
return "articles"
|
||||
}
|
||||
@@ -4,14 +4,14 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ArticleTag represents the many-to-many relationship between articles and tags.
|
||||
// ArticleTag 表示文章与标签之间的多对多关联。
|
||||
type ArticleTag struct {
|
||||
ArticleID uint `gorm:"primaryKey;index" json:"article_id"`
|
||||
TagID uint `gorm:"primaryKey;index" json:"tag_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (ArticleTag) TableName() string {
|
||||
return "article_tags"
|
||||
}
|
||||
@@ -6,13 +6,13 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ArticleView represents a unique article view record.
|
||||
// Each record tracks one unique visit (by IP or user) to an article.
|
||||
// ArticleView 表示一条唯一的文章浏览记录。
|
||||
// 每条记录跟踪一次(按 IP 或用户)对文章的唯一访问。
|
||||
type ArticleView struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ArticleID uint `gorm:"not null;index:idx_article_views_article" json:"article_id"`
|
||||
UserID *uint `gorm:"index:idx_article_views_user" json:"user_id"` // NULL for anonymous users
|
||||
UserID *uint `gorm:"index:idx_article_views_user" json:"user_id"` // 匿名用户为 NULL
|
||||
IP string `gorm:"size:64;not null;index:idx_article_views_ip" json:"ip"`
|
||||
UserAgent string `gorm:"size:512" json:"user_agent"`
|
||||
IsBot bool `gorm:"default:false;index:idx_article_views_bot" json:"is_bot"`
|
||||
@@ -20,13 +20,13 @@ type ArticleView struct {
|
||||
User *User `gorm:"foreignKey:UserID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (ArticleView) TableName() string {
|
||||
return "article_views"
|
||||
}
|
||||
|
||||
// BeforeCreate hook to ensure we don't create duplicate records.
|
||||
// This is a safety check in addition to application-level deduplication.
|
||||
// BeforeCreate 钩子确保不会创建重复记录。
|
||||
// 这是在应用层去重之外的另一道安全校验。
|
||||
func (av *ArticleView) BeforeCreate(tx *gorm.DB) error {
|
||||
var count int64
|
||||
query := tx.Model(&ArticleView{}).
|
||||
@@ -40,7 +40,7 @@ func (av *ArticleView) BeforeCreate(tx *gorm.DB) error {
|
||||
|
||||
query.Count(&count)
|
||||
if count > 0 {
|
||||
// Record already exists, skip creation
|
||||
// 记录已存在,跳过创建
|
||||
return gorm.ErrDuplicatedKey
|
||||
}
|
||||
|
||||
|
||||
+16
-19
@@ -6,27 +6,25 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Attachment represents a file attached to an article.
|
||||
// Attachment 表示附加到文章的文件。
|
||||
//
|
||||
// Lifecycle (plan A — upload-then-bind):
|
||||
// - On the article-create page the article does not exist yet, so ArticleID
|
||||
// is 0 and the row is temporarily owned by SessionToken (a random value
|
||||
// generated for the page session).
|
||||
// - When the article is saved, ArticleCreate binds pending rows by
|
||||
// SessionToken, setting their ArticleID and clearing the token.
|
||||
// - On the edit page uploads carry the real ArticleID directly.
|
||||
// 生命周期(方案 A——先上传后绑定):
|
||||
// - 在文章创建页面上文章尚不存在,因此 ArticleID 为 0,
|
||||
// 该行暂时由 SessionToken(为页面会话生成的随机值)持有。
|
||||
// - 保存文章时,ArticleCreate 通过 SessionToken 绑定待处理行,
|
||||
// 设置它们的 ArticleID 并清除令牌。
|
||||
// - 在编辑页面上,上传直接携带真实的 ArticleID。
|
||||
//
|
||||
// Disk deduplication: StoredName is the SHA-256 of the file content. Before
|
||||
// writing, the handler checks whether a file with that name already exists on
|
||||
// disk; if so it is reused (no rewrite). Deletion uses reference counting —
|
||||
// the disk file is removed only when no Attachment rows reference it.
|
||||
// 磁盘去重:StoredName 是文件内容的 SHA-256。写入前,
|
||||
// 处理器会检查磁盘上是否已存在同名文件;若已存在则复用(不重写)。
|
||||
// 删除采用引用计数——仅当没有任何 Attachment 行引用时,才删除磁盘文件。
|
||||
type Attachment struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
ArticleID uint `gorm:"index" json:"article_id"` // 0 while pending on the create page
|
||||
SessionToken string `gorm:"size:64;index" json:"-"` // temporary ownership token for the create page
|
||||
ArticleID uint `gorm:"index" json:"article_id"` // 在创建页面上待绑定时为 0
|
||||
SessionToken string `gorm:"size:64;index" json:"-"` // 创建页面上的临时归属令牌
|
||||
UploaderID uint `gorm:"index" json:"uploader_id"`
|
||||
Filename string `gorm:"size:255" json:"filename"` // original filename
|
||||
StoredName string `gorm:"size:64;index" json:"stored_name"` // SHA-256 hex, the on-disk filename
|
||||
Filename string `gorm:"size:255" json:"filename"` // 原始文件名
|
||||
StoredName string `gorm:"size:64;index" json:"stored_name"` // SHA-256 十六进制字符串,磁盘文件名
|
||||
Ext string `gorm:"size:32" json:"ext"`
|
||||
MIME string `gorm:"size:128" json:"mime"`
|
||||
Size int64 `gorm:"default:0" json:"size"`
|
||||
@@ -36,13 +34,12 @@ type Attachment struct {
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (Attachment) TableName() string {
|
||||
return "attachments"
|
||||
}
|
||||
|
||||
// AttachmentCategoryImage reports whether this attachment is an image (used to
|
||||
// decide markdown insertion form: ![]() vs []()).
|
||||
// IsImage 报告该附件是否为图片(用于决定 Markdown 插入形式:![]() 还是 []())。
|
||||
func (a *Attachment) IsImage() bool {
|
||||
return a.Category == CategoryImage
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// botPatterns contains common bot/crawler/spider User-Agent patterns.
|
||||
// botPatterns 包含常见的 bot/爬虫/蜘蛛 User-Agent 特征模式。
|
||||
var botPatterns = []string{
|
||||
"bot", "crawler", "spider", "scraper", "scraping",
|
||||
"googlebot", "bingbot", "baiduspider", "yandexbot",
|
||||
@@ -19,8 +19,8 @@ var botPatterns = []string{
|
||||
"headless", "phantom", "selenium", "puppeteer",
|
||||
}
|
||||
|
||||
// IsBot checks if the given User-Agent string matches known bot patterns.
|
||||
// It performs a case-insensitive substring match against common bot identifiers.
|
||||
// IsBot 检查给定的 User-Agent 字符串是否匹配已知的 bot 特征模式。
|
||||
// 它针对常见的 bot 标识进行不区分大小写的子串匹配。
|
||||
func IsBot(userAgent string) bool {
|
||||
if userAgent == "" {
|
||||
return false
|
||||
|
||||
+16
-18
@@ -10,16 +10,16 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Comment status constants.
|
||||
// 评论状态常量。
|
||||
const (
|
||||
CommentPending = 0 // 待审核
|
||||
CommentApproved = 1 // 已通过
|
||||
CommentRejected = 2 // 已拒绝(软拒绝;后台仍可查看,但前台不再显示)
|
||||
)
|
||||
|
||||
// Comment represents one reader-submitted comment on an article. Comments may
|
||||
// be nested via ParentID and authored by either a logged-in user (UserID) or
|
||||
// an anonymous visitor identified by a long-lived GuestToken cookie.
|
||||
// Comment 表示读者对文章提交的一条评论。评论可通过 ParentID 进行嵌套,
|
||||
// 作者可以是已登录用户(UserID),也可以通过长期有效的 GuestToken Cookie
|
||||
// 标识的匿名访客。
|
||||
type Comment struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -29,9 +29,9 @@ type Comment struct {
|
||||
ArticleID uint `gorm:"not null;index" json:"article_id"`
|
||||
ParentID *uint `gorm:"index" json:"parent_id,omitempty"`
|
||||
|
||||
// Authorship: logged-in users get UserID; anonymous visitors get a random
|
||||
// GuestToken stored in a cookie so they can see their own pending/private
|
||||
// comments on subsequent page loads.
|
||||
// 作者标识:已登录用户使用 UserID;匿名访客使用随机的
|
||||
// GuestToken(保存于 Cookie 中),以便后续页面加载时能查看
|
||||
// 自己待审核/私密的评论。
|
||||
UserID *uint `gorm:"index" json:"user_id,omitempty"`
|
||||
GuestToken string `gorm:"size:64;index" json:"-"`
|
||||
|
||||
@@ -51,20 +51,20 @@ type Comment struct {
|
||||
Article Article `gorm:"foreignKey:ArticleID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (Comment) TableName() string {
|
||||
return "comments"
|
||||
}
|
||||
|
||||
// HashEmail returns the md5 hash of a lowercase, trimmed email address. This
|
||||
// is the form expected by Gravatar.
|
||||
// HashEmail 返回小写并去除空格后的邮箱地址的 md5 哈希值。
|
||||
// 这是 Gravatar 所期望的格式。
|
||||
func HashEmail(email string) string {
|
||||
sum := md5.Sum([]byte(strings.ToLower(strings.TrimSpace(email))))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// GravatarURL returns a Gravatar avatar URL for this comment's email hash.
|
||||
// Falls back to the "identicon" default avatar when no Gravatar exists.
|
||||
// GravatarURL 根据此评论的邮箱哈希返回 Gravatar 头像 URL。
|
||||
// 当没有对应的 Gravatar 头像时,回退到 "identicon" 默认头像。
|
||||
func (c *Comment) GravatarURL(size int) string {
|
||||
if size <= 0 {
|
||||
size = 64
|
||||
@@ -76,9 +76,8 @@ func (c *Comment) GravatarURL(size int) string {
|
||||
return fmt.Sprintf("https://www.gravatar.com/avatar/%s?s=%d&d=identicon", hash, size)
|
||||
}
|
||||
|
||||
// MaskedEmail returns the email with the local part partially obscured, for
|
||||
// admin-side listings that should hint at identity without exposing the
|
||||
// full address.
|
||||
// MaskedEmail 返回把本地部分部分遮盖后的邮箱,供后台列表展示:
|
||||
// 既能提示身份,又不暴露完整地址。
|
||||
func (c *Comment) MaskedEmail() string {
|
||||
email := c.Email
|
||||
at := strings.LastIndex(email, "@")
|
||||
@@ -93,9 +92,8 @@ func (c *Comment) MaskedEmail() string {
|
||||
return string(local[0]) + "***" + string(local[len(local)-1]) + host
|
||||
}
|
||||
|
||||
// AuthorInitial returns an uppercase first character of AuthorName for use as
|
||||
// a text-based avatar placeholder when Gravatar is disabled. Returns "?" when
|
||||
// the name is empty.
|
||||
// AuthorInitial 返回 AuthorName 的首个大写字符,用作禁用 Gravatar 时
|
||||
// 的文本头像占位符。名称为空时返回 "?"。
|
||||
func (c *Comment) AuthorInitial() string {
|
||||
name := strings.TrimSpace(c.AuthorName)
|
||||
if name == "" {
|
||||
|
||||
+13
-11
@@ -2,30 +2,32 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// CommentConfig holds the singleton (id=1) global comment policy.
|
||||
// CommentConfig 保存单例(id=1)的全局评论策略。
|
||||
type CommentConfig struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"` // master switch for the comment system
|
||||
AllowGuest bool `gorm:"default:true" json:"allow_guest"` // whether anonymous (non-logged-in) comments are allowed
|
||||
GuestRequireApproval bool `gorm:"default:false" json:"guest_require_approval"` // hold guest comments in the moderation queue
|
||||
UseGravatar bool `gorm:"default:true" json:"use_gravatar"` // when false, avatars render as a text-initial placeholder
|
||||
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"` // 评论系统的总开关
|
||||
AllowGuest bool `gorm:"default:true" json:"allow_guest"` // 是否允许匿名(非登录)评论
|
||||
GuestRequireApproval bool `gorm:"default:false" json:"guest_require_approval"` // 将访客评论置于审核队列
|
||||
// SECURITY_TODO #15:Gravatar 可通过反向查询暴露 MD5(邮箱);
|
||||
// 新部署默认关闭(管理员可显式重新启用)。
|
||||
UseGravatar bool `gorm:"default:false" json:"use_gravatar"` // 为 false 时,头像显示为文本首字母占位
|
||||
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (CommentConfig) TableName() string {
|
||||
return "comment_configs"
|
||||
}
|
||||
|
||||
// defaultCommentConfig returns the in-memory fallback used before the DB row is
|
||||
// seeded, matching the seed defaults.
|
||||
// defaultCommentConfig 返回数据库行被初始化之前使用的内存回退值,
|
||||
// 与初始化种子默认值保持一致。
|
||||
func defaultCommentConfig() *CommentConfig {
|
||||
return &CommentConfig{
|
||||
ID: 1,
|
||||
Enabled: true,
|
||||
AllowGuest: true,
|
||||
GuestRequireApproval: false,
|
||||
UseGravatar: true,
|
||||
UseGravatar: false,
|
||||
}
|
||||
}
|
||||
+17
-20
@@ -6,10 +6,9 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// configCache holds process-level caches of platform configuration so that
|
||||
// per-request rendering and upload validation do not hit the database. The
|
||||
// cache is populated once at startup and refreshed whenever an admin saves a
|
||||
// settings page (see RefreshConfigCache / the admin handlers).
|
||||
// configCache 保存平台配置的进程级缓存,使每次请求的渲染与上传校验
|
||||
// 都不必访问数据库。缓存启动时填充一次,每当管理员保存设置页面时刷新
|
||||
// (参见 RefreshConfigCache / 各管理处理器)。
|
||||
var configCache = struct {
|
||||
mu sync.RWMutex
|
||||
site *SiteSetting
|
||||
@@ -23,8 +22,8 @@ var configCache = struct {
|
||||
upload: &UploadConfig{Enabled: true, DefaultMaxSize: DefaultUploadMaxSize, StorageDir: "attachments"},
|
||||
}
|
||||
|
||||
// LoadConfigCache reads all platform configuration from the database into the
|
||||
// process cache. Called once at startup after InitDB.
|
||||
// LoadConfigCache 将所有平台配置从数据库读入进程缓存。
|
||||
// 在 InitDB 之后于启动时调用一次。
|
||||
func LoadConfigCache(db *gorm.DB) {
|
||||
configCache.mu.Lock()
|
||||
defer configCache.mu.Unlock()
|
||||
@@ -66,57 +65,55 @@ func LoadConfigCache(db *gorm.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshConfigCache reloads all cached platform configuration. Admin handlers
|
||||
// call this after writing changes so the next request sees them.
|
||||
// RefreshConfigCache 重新加载所有缓存中的平台配置。管理处理器在写入变更后
|
||||
// 调用此方法,以便下一次请求能看到更新。
|
||||
func RefreshConfigCache(db *gorm.DB) {
|
||||
LoadConfigCache(db)
|
||||
}
|
||||
|
||||
// GetSiteSetting returns a pointer to the cached site settings (read-only copy
|
||||
// semantics: callers must not mutate).
|
||||
// GetSiteSetting 返回缓存的站点设置指针(只读副本语义:调用方不得修改)。
|
||||
func GetSiteSetting() *SiteSetting {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
return configCache.site
|
||||
}
|
||||
|
||||
// GetUploadConfig returns the cached upload policy.
|
||||
// GetUploadConfig 返回缓存的上传策略。
|
||||
func GetUploadConfig() *UploadConfig {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
return configCache.upload
|
||||
}
|
||||
|
||||
// GetCommentConfig returns the cached comment policy.
|
||||
// GetCommentConfig 返回缓存的评论策略。
|
||||
func GetCommentConfig() *CommentConfig {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
return configCache.comment
|
||||
}
|
||||
|
||||
// GetUploadFileTypes returns the cached list of permitted file types.
|
||||
// GetUploadFileTypes 返回缓存的允许文件类型列表。
|
||||
func GetUploadFileTypes() []UploadFileType {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
return configCache.types
|
||||
}
|
||||
|
||||
// GetDownloadBaseURLs returns the cached list of download base URLs.
|
||||
// GetDownloadBaseURLs 返回缓存的下载基础 URL 列表。
|
||||
func GetDownloadBaseURLs() []DownloadBaseURL {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
return configCache.baseURLs
|
||||
}
|
||||
|
||||
// DefaultDownloadBaseURL returns the base URL used to build attachment download
|
||||
// links: the enabled row marked IsDefault, else the highest-priority enabled
|
||||
// row. Returns an empty string if none is configured.
|
||||
// DefaultDownloadBaseURL 返回用于构建附件下载链接的基础 URL:
|
||||
// 首选标记为 IsDefault 且启用的行,否则选择优先级最高的启用行。
|
||||
// 若未配置任何项则返回空字符串。
|
||||
func DefaultDownloadBaseURL() string {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
|
||||
// Rows are ordered is_default desc, priority asc, so the first enabled row
|
||||
// is the right pick.
|
||||
// 行按 is_default desc、priority asc 排序,因此第一个启用行即为正确选择。
|
||||
for _, b := range configCache.baseURLs {
|
||||
if b.Enabled {
|
||||
return b.BaseURL
|
||||
@@ -125,7 +122,7 @@ func DefaultDownloadBaseURL() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetNavLinks returns the cached list of enabled navigation links, sorted by sort order.
|
||||
// GetNavLinks 返回缓存的启用导航链接列表,按排序顺序排列。
|
||||
func GetNavLinks() []NavLink {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
|
||||
+32
-10
@@ -1,6 +1,7 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -14,12 +15,30 @@ import (
|
||||
"github.com/glebarez/sqlite"
|
||||
)
|
||||
|
||||
// DB is the global database connection, initialized by InitDB.
|
||||
// DB 是全局数据库连接,由 InitDB 初始化。
|
||||
var DB *gorm.DB
|
||||
|
||||
// InitDB opens the database connection, runs migrations, and seeds the admin user.
|
||||
// adminPasswordAlphabet 避免了视觉上易混淆的字符(不含 l、I、O、0、1),
|
||||
// 用于生成首次运行的管理员密码。
|
||||
const adminPasswordAlphabet = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789" + "^$*+?%"
|
||||
|
||||
// randomAdminPassword 返回密码学随机的 16 位首次运行管理员密码
|
||||
// (SECURITY_TODO #12:不再硬编码 admin/admin)。
|
||||
func randomAdminPassword() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
log.Fatalf("Failed to generate admin password: %v", err)
|
||||
}
|
||||
out := make([]byte, len(b))
|
||||
for i, v := range b {
|
||||
out[i] = adminPasswordAlphabet[int(v)%len(adminPasswordAlphabet)]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// InitDB 打开数据库连接、执行迁移并初始化管理员用户。
|
||||
func InitDB(cfg *config.Config) *gorm.DB {
|
||||
// Ensure the storage path exists.
|
||||
// 确保存储目录存在。
|
||||
if err := os.MkdirAll(cfg.Path, 0755); err != nil {
|
||||
log.Fatalf("Failed to create storage directory %s: %v", cfg.Path, err)
|
||||
}
|
||||
@@ -44,18 +63,18 @@ func InitDB(cfg *config.Config) *gorm.DB {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
|
||||
// Auto-migrate tables (idempotent).
|
||||
// 自动迁移数据表(幂等操作)。
|
||||
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}, &Attachment{}, &Comment{}, &CommentConfig{}, &ArticleView{}, &NavLink{}, &Tag{}, &ArticleTag{}); err != nil {
|
||||
log.Fatalf("Failed to auto-migrate database: %v", err)
|
||||
}
|
||||
|
||||
// Seed site platform configuration on first run.
|
||||
// 首次运行时初始化站点平台配置。
|
||||
seedSiteSettings(db)
|
||||
seedUploadConfig(db)
|
||||
seedUploadFileTypes(db)
|
||||
seedCommentConfig(db)
|
||||
|
||||
// First-run seed: create admin user if no users exist.
|
||||
// 首次运行初始化:若不存在任何用户则创建管理员用户。
|
||||
var count int64
|
||||
db.Model(&User{}).Count(&count)
|
||||
if count == 0 {
|
||||
@@ -66,21 +85,24 @@ func InitDB(cfg *config.Config) *gorm.DB {
|
||||
Status: StatusNormal,
|
||||
Role: RoleAdmin,
|
||||
}
|
||||
if err := admin.SetPassword("admin"); err != nil {
|
||||
adminPassword := randomAdminPassword()
|
||||
if err := admin.SetPassword(adminPassword); err != nil {
|
||||
log.Fatalf("Failed to hash admin password: %v", err)
|
||||
}
|
||||
if err := db.Create(admin).Error; err != nil {
|
||||
log.Fatalf("Failed to create admin user: %v", err)
|
||||
}
|
||||
// SECURITY_TODO #12:首次运行密码为密码学随机生成,且只打印一次——
|
||||
// 请立即抄写;之后将无法找回。
|
||||
log.Println("==============================================")
|
||||
log.Println(" First run: created default admin user.")
|
||||
log.Println(" Username: admin")
|
||||
log.Println(" Password: admin")
|
||||
log.Println(" Please change this password immediately!")
|
||||
log.Println(" Password: " + adminPassword)
|
||||
log.Println(" This password is shown ONCE. Change it after login!")
|
||||
log.Println("==============================================")
|
||||
}
|
||||
|
||||
// Migration fix: always set admin role on the admin user.
|
||||
// 迁移修复:始终为 admin 用户设置管理员角色。
|
||||
result := db.Model(&User{}).Where("username = ?", "admin").Update("role", RoleAdmin)
|
||||
if result.RowsAffected > 0 {
|
||||
log.Printf("Migration: set admin role for existing admin user (rows affected: %d)", result.RowsAffected)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRandomAdminPassword 覆盖 SECURITY_TODO #12:首次运行的管理员密码
|
||||
// 来自无歧义安全的字符表、长度固定,且每次生成结果不同。
|
||||
func TestRandomAdminPassword(t *testing.T) {
|
||||
pw := randomAdminPassword()
|
||||
if len(pw) != 16 {
|
||||
t.Fatalf("password length = %d, want 16", len(pw))
|
||||
}
|
||||
for _, c := range pw {
|
||||
if !strings.ContainsRune(adminPasswordAlphabet, c) {
|
||||
t.Fatalf("password contains rune %q outside alphabet", c)
|
||||
}
|
||||
}
|
||||
if pw == randomAdminPassword() {
|
||||
t.Fatal("two generated passwords are identical")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGravatarOffByDefault 覆盖 SECURITY_TODO #15:新部署不得将 MD5(邮箱)
|
||||
// 泄露给 Gravatar,除非管理员明确启用。
|
||||
func TestGravatarOffByDefault(t *testing.T) {
|
||||
cc := defaultCommentConfig()
|
||||
if cc.UseGravatar {
|
||||
t.Fatal("default CommentConfig enables Gravatar")
|
||||
}
|
||||
}
|
||||
+9
-10
@@ -2,27 +2,26 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// NavLink represents a custom navigation link in the header.
|
||||
// NavLink 表示页头中的自定义导航链接。
|
||||
type NavLink struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
TitleZh string `gorm:"size:100;not null" json:"title_zh"` // Link text (Chinese)
|
||||
TitleEn string `gorm:"size:100;not null" json:"title_en"` // Link text (English)
|
||||
URL string `gorm:"size:512;not null" json:"url"` // Target URL
|
||||
OpenNew bool `gorm:"default:false" json:"open_new"` // Open in new window
|
||||
Enabled bool `gorm:"default:true" json:"enabled"` // Show/hide link
|
||||
Sort int `gorm:"default:0;index" json:"sort"` // Display order (lower = first)
|
||||
TitleZh string `gorm:"size:100;not null" json:"title_zh"` // 链接文本(中文)
|
||||
TitleEn string `gorm:"size:100;not null" json:"title_en"` // 链接文本(英文)
|
||||
URL string `gorm:"size:512;not null" json:"url"` // 目标 URL
|
||||
OpenNew bool `gorm:"default:false" json:"open_new"` // 在新窗口中打开
|
||||
Enabled bool `gorm:"default:true" json:"enabled"` // 显示/隐藏链接
|
||||
Sort int `gorm:"default:0;index" json:"sort"` // 显示顺序(数值越小越靠前)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (NavLink) TableName() string {
|
||||
return "nav_links"
|
||||
}
|
||||
|
||||
// Title returns the link text for the given language code, falling back to
|
||||
// the other language when the requested one is empty.
|
||||
// Title 返回指定语言代码下的链接文本,当所请求语言为空时回退到另一语言。
|
||||
func (n *NavLink) Title(lang string) string {
|
||||
if lang == "zh" {
|
||||
if n.TitleZh != "" {
|
||||
|
||||
+13
-13
@@ -6,9 +6,8 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// seedSiteSettings inserts the singleton site_settings row (id=1) if absent.
|
||||
// Text fields are left empty so templates fall back to i18n defaults until an
|
||||
// admin configures them.
|
||||
// seedSiteSettings 在不存在时插入单例的 site_settings 行(id=1)。
|
||||
// 文本字段留空,以便模板回退到 i18n 默认值,直到管理员配置它们为止。
|
||||
func seedSiteSettings(db *gorm.DB) {
|
||||
var count int64
|
||||
db.Model(&SiteSetting{}).Count(&count)
|
||||
@@ -21,7 +20,7 @@ func seedSiteSettings(db *gorm.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
// seedUploadConfig inserts the singleton upload_configs row (id=1) if absent.
|
||||
// seedUploadConfig 在不存在时插入单例的 upload_configs 行(id=1)。
|
||||
func seedUploadConfig(db *gorm.DB) {
|
||||
var count int64
|
||||
db.Model(&UploadConfig{}).Count(&count)
|
||||
@@ -39,7 +38,7 @@ func seedUploadConfig(db *gorm.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
// seedCommentConfig inserts the singleton comment_configs row (id=1) if absent.
|
||||
// seedCommentConfig 在不存在时插入单例的 comment_configs 行(id=1)。
|
||||
func seedCommentConfig(db *gorm.DB) {
|
||||
var count int64
|
||||
db.Model(&CommentConfig{}).Count(&count)
|
||||
@@ -51,23 +50,24 @@ func seedCommentConfig(db *gorm.DB) {
|
||||
Enabled: true,
|
||||
AllowGuest: true,
|
||||
GuestRequireApproval: false,
|
||||
UseGravatar: true,
|
||||
// SECURITY_TODO #15:Gravatar 可通过反向查询暴露 MD5(邮箱);
|
||||
// 新部署默认关闭,管理员可在评论设置页面重新启用。
|
||||
UseGravatar: false,
|
||||
}
|
||||
if err := db.Create(c).Error; err != nil {
|
||||
log.Printf("Warning: failed to seed comment_configs: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultUploadFileTypes is the set of commonly permitted attachment types
|
||||
// seeded on first run.
|
||||
// defaultUploadFileTypes 是首次运行初始化的常用允许附件类型集合。
|
||||
var defaultUploadFileTypes = []UploadFileType{
|
||||
// Images
|
||||
// 图片
|
||||
{Extension: ".jpg", MimeType: "image/jpeg", Category: CategoryImage, Enabled: true, Sort: 1},
|
||||
{Extension: ".jpeg", MimeType: "image/jpeg", Category: CategoryImage, Enabled: true, Sort: 2},
|
||||
{Extension: ".png", MimeType: "image/png", Category: CategoryImage, Enabled: true, Sort: 3},
|
||||
{Extension: ".gif", MimeType: "image/gif", Category: CategoryImage, Enabled: true, Sort: 4},
|
||||
{Extension: ".webp", MimeType: "image/webp", Category: CategoryImage, Enabled: true, Sort: 5},
|
||||
// Documents
|
||||
// 文档
|
||||
{Extension: ".pdf", MimeType: "application/pdf", Category: CategoryDocument, Enabled: true, Sort: 10},
|
||||
{Extension: ".doc", MimeType: "application/msword", Category: CategoryDocument, Enabled: true, Sort: 11},
|
||||
{Extension: ".docx", MimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", Category: CategoryDocument, Enabled: true, Sort: 12},
|
||||
@@ -76,16 +76,16 @@ var defaultUploadFileTypes = []UploadFileType{
|
||||
{Extension: ".ppt", MimeType: "application/vnd.ms-powerpoint", Category: CategoryDocument, Enabled: true, Sort: 15},
|
||||
{Extension: ".pptx", MimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation", Category: CategoryDocument, Enabled: true, Sort: 16},
|
||||
{Extension: ".txt", MimeType: "text/plain", Category: CategoryDocument, Enabled: true, Sort: 17},
|
||||
// Archives
|
||||
// 压缩包
|
||||
{Extension: ".zip", MimeType: "application/zip", Category: CategoryArchive, Enabled: true, Sort: 20},
|
||||
{Extension: ".rar", MimeType: "application/vnd.rar", Category: CategoryArchive, Enabled: true, Sort: 21},
|
||||
{Extension: ".7z", MimeType: "application/x-7z-compressed", Category: CategoryArchive, Enabled: true, Sort: 22},
|
||||
// Video
|
||||
// 视频
|
||||
{Extension: ".mp4", MimeType: "video/mp4", Category: CategoryVideo, Enabled: true, Sort: 30},
|
||||
{Extension: ".avi", MimeType: "video/x-msvideo", Category: CategoryVideo, Enabled: true, Sort: 31},
|
||||
}
|
||||
|
||||
// seedUploadFileTypes seeds the permitted file-type rows if the table is empty.
|
||||
// seedUploadFileTypes 在表为空时初始化允许的文件类型行。
|
||||
func seedUploadFileTypes(db *gorm.DB) {
|
||||
var count int64
|
||||
db.Model(&UploadFileType{}).Count(&count)
|
||||
|
||||
+34
-34
@@ -2,35 +2,37 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// SiteSetting holds the singleton (id=1) global display configuration:
|
||||
// logo, top-left title, header banner text, and footer text, each with
|
||||
// zh/en variants that fall back to i18n defaults when empty.
|
||||
// SiteSetting 保存单例(id=1)的全局展示配置:
|
||||
// 徽标、左上角标题、页头横幅文案和页脚文案,每项均有 zh/en 变体,
|
||||
// 为空时回退到 i18n 默认值。
|
||||
type SiteSetting struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Logo string `gorm:"size:512" json:"logo"` // local filename (served under /uploads/logos) OR a full URL
|
||||
Favicon string `gorm:"size:512" json:"favicon"` // local filename (served under /uploads/logos) OR a full URL
|
||||
LogoTextZh string `gorm:"size:255" json:"logo_text_zh"` // top-left title (zh)
|
||||
LogoTextEn string `gorm:"size:255" json:"logo_text_en"` // top-left title (en)
|
||||
HeaderTextZh string `gorm:"size:512" json:"header_text_zh"` // header banner text (zh)
|
||||
HeaderTextEn string `gorm:"size:512" json:"header_text_en"` // header banner text (en)
|
||||
HomeWelcomeZh string `gorm:"size:255" json:"home_welcome_zh"` // home page welcome heading (zh)
|
||||
HomeWelcomeEn string `gorm:"size:255" json:"home_welcome_en"` // home page welcome heading (en)
|
||||
HomeSubtitleZh string `gorm:"size:512" json:"home_subtitle_zh"` // home page subtitle (zh)
|
||||
HomeSubtitleEn string `gorm:"size:512" json:"home_subtitle_en"` // home page subtitle (en)
|
||||
FooterTextZh string `gorm:"size:512" json:"footer_text_zh"` // footer text (zh)
|
||||
FooterTextEn string `gorm:"size:512" json:"footer_text_en"` // footer text (en)
|
||||
AllowRegistration bool `gorm:"default:false" json:"allow_registration"` // whether users can self-register
|
||||
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Logo string `gorm:"size:512" json:"logo"` // 本地文件名(供 /uploads/logos 目录提供)或完整 URL
|
||||
Favicon string `gorm:"size:512" json:"favicon"` // 本地文件名(供 /uploads/logos 目录提供)或完整 URL
|
||||
LogoTextZh string `gorm:"size:255" json:"logo_text_zh"` // 左上角标题(中文)
|
||||
LogoTextEn string `gorm:"size:255" json:"logo_text_en"` // 左上角标题(英文)
|
||||
HeaderTextZh string `gorm:"size:512" json:"header_text_zh"` // 页头横幅文案(中文)
|
||||
HeaderTextEn string `gorm:"size:512" json:"header_text_en"` // 页头横幅文案(英文)
|
||||
HomeWelcomeZh string `gorm:"size:255" json:"home_welcome_zh"` // 首页欢迎标题(中文)
|
||||
HomeWelcomeEn string `gorm:"size:255" json:"home_welcome_en"` // 首页欢迎标题(英文)
|
||||
HomeSubtitleZh string `gorm:"size:512" json:"home_subtitle_zh"`// 首页副标题(中文)
|
||||
HomeSubtitleEn string `gorm:"size:512" json:"home_subtitle_en"`// 首页副标题(英文)
|
||||
FooterTextZh string `gorm:"size:512" json:"footer_text_zh"` // 页脚文案(中文)
|
||||
FooterTextEn string `gorm:"size:512" json:"footer_text_en"` // 页脚文案(英文)
|
||||
// SiteURL 是用于 RSS/订阅链接的规范化站点基础 URL(SECURITY_TODO #16);
|
||||
// 为空时在运行时回退到请求的 Host。
|
||||
SiteURL string `gorm:"size:512" json:"site_url"`
|
||||
AllowRegistration bool `gorm:"default:false" json:"allow_registration"` // 是否允许用户自助注册
|
||||
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (SiteSetting) TableName() string {
|
||||
return "site_settings"
|
||||
}
|
||||
|
||||
// LogoIsURL reports whether the logo value is an external URL rather than a
|
||||
// local filename.
|
||||
// LogoIsURL 报告徽标值是否为外部 URL 而非本地文件名。
|
||||
func (s *SiteSetting) LogoIsURL() bool {
|
||||
if s == nil || s.Logo == "" {
|
||||
return false
|
||||
@@ -38,8 +40,7 @@ func (s *SiteSetting) LogoIsURL() bool {
|
||||
return len(s.Logo) >= 4 && (s.Logo[:4] == "http")
|
||||
}
|
||||
|
||||
// FaviconIsURL reports whether the favicon value is an external URL rather than a
|
||||
// local filename.
|
||||
// FaviconIsURL 报告 favicon 值是否为外部 URL 而非本地文件名。
|
||||
func (s *SiteSetting) FaviconIsURL() bool {
|
||||
if s == nil || s.Favicon == "" {
|
||||
return false
|
||||
@@ -47,8 +48,7 @@ func (s *SiteSetting) FaviconIsURL() bool {
|
||||
return len(s.Favicon) >= 4 && (s.Favicon[:4] == "http")
|
||||
}
|
||||
|
||||
// LogoText returns the title for the given language code, falling back to the
|
||||
// other language when the requested one is empty.
|
||||
// LogoText 返回指定语言代码下的标题,当所请求语言为空时回退到另一语言。
|
||||
func (s *SiteSetting) LogoText(lang string) string {
|
||||
if lang == "zh" {
|
||||
if s.LogoTextZh != "" {
|
||||
@@ -62,8 +62,8 @@ func (s *SiteSetting) LogoText(lang string) string {
|
||||
return s.LogoTextZh
|
||||
}
|
||||
|
||||
// HeaderText returns the header banner text for the given language code,
|
||||
// falling back to the other language when the requested one is empty.
|
||||
// HeaderText 返回指定语言代码下的页头横幅文案,当所请求语言为空时
|
||||
// 回退到另一语言。
|
||||
func (s *SiteSetting) HeaderText(lang string) string {
|
||||
if lang == "zh" {
|
||||
if s.HeaderTextZh != "" {
|
||||
@@ -77,8 +77,8 @@ func (s *SiteSetting) HeaderText(lang string) string {
|
||||
return s.HeaderTextZh
|
||||
}
|
||||
|
||||
// HomeWelcome returns the home page welcome heading for the given language,
|
||||
// falling back to the other language when the requested one is empty.
|
||||
// HomeWelcome 返回指定语言下的首页欢迎标题,当所请求语言为空时
|
||||
// 回退到另一语言。
|
||||
func (s *SiteSetting) HomeWelcome(lang string) string {
|
||||
if lang == "zh" {
|
||||
if s.HomeWelcomeZh != "" {
|
||||
@@ -92,8 +92,8 @@ func (s *SiteSetting) HomeWelcome(lang string) string {
|
||||
return s.HomeWelcomeZh
|
||||
}
|
||||
|
||||
// HomeSubtitle returns the home page subtitle for the given language,
|
||||
// falling back to the other language when the requested one is empty.
|
||||
// HomeSubtitle 返回指定语言下的首页副标题,当所请求语言为空时
|
||||
// 回退到另一语言。
|
||||
func (s *SiteSetting) HomeSubtitle(lang string) string {
|
||||
if lang == "zh" {
|
||||
if s.HomeSubtitleZh != "" {
|
||||
@@ -107,8 +107,8 @@ func (s *SiteSetting) HomeSubtitle(lang string) string {
|
||||
return s.HomeSubtitleZh
|
||||
}
|
||||
|
||||
// FooterText returns the footer text for the given language code, falling
|
||||
// back to the other language when the requested one is empty.
|
||||
// FooterText 返回指定语言代码下的页脚文案,当所请求语言为空时
|
||||
// 回退到另一语言。
|
||||
func (s *SiteSetting) FooterText(lang string) string {
|
||||
if lang == "zh" {
|
||||
if s.FooterTextZh != "" {
|
||||
|
||||
+14
-14
@@ -7,7 +7,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Tag represents a blog post tag with multi-language support.
|
||||
// Tag 表示支持多语言的博客文章标签。
|
||||
type Tag struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
NameZh string `gorm:"size:50;not null" json:"name_zh"`
|
||||
@@ -18,12 +18,12 @@ type Tag struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (Tag) TableName() string {
|
||||
return "tags"
|
||||
}
|
||||
|
||||
// Name returns the tag name for the specified language.
|
||||
// Name 返回指定语言下的标签名称。
|
||||
func (t *Tag) Name(lang string) string {
|
||||
if lang == "zh" {
|
||||
return t.NameZh
|
||||
@@ -31,7 +31,7 @@ func (t *Tag) Name(lang string) string {
|
||||
return t.NameEn
|
||||
}
|
||||
|
||||
// generateTagSlug creates a URL-friendly slug from tag name.
|
||||
// generateTagSlug 根据标签名称生成对 URL 友好的 slug。
|
||||
func generateTagSlug(name string) string {
|
||||
slug := strings.ToLower(strings.TrimSpace(name))
|
||||
slug = strings.ReplaceAll(slug, " ", "-")
|
||||
@@ -39,13 +39,13 @@ func generateTagSlug(name string) string {
|
||||
return slug
|
||||
}
|
||||
|
||||
// FindOrCreateTag finds a tag by name or creates it if it doesn't exist.
|
||||
// If both nameZh and nameEn are provided, it uses them; otherwise uses the same name for both languages.
|
||||
// FindOrCreateTag 按名称查找标签,不存在则创建。
|
||||
// 若同时提供 nameZh 与 nameEn 则分别使用;否则两种语言使用相同的名称。
|
||||
func FindOrCreateTag(db *gorm.DB, nameZh, nameEn string) (*Tag, error) {
|
||||
nameZh = strings.TrimSpace(nameZh)
|
||||
nameEn = strings.TrimSpace(nameEn)
|
||||
|
||||
// If only one name is provided, use it for both languages
|
||||
// 若只提供其中一个名称,两种语言都使用它
|
||||
if nameZh == "" && nameEn != "" {
|
||||
nameZh = nameEn
|
||||
} else if nameEn == "" && nameZh != "" {
|
||||
@@ -68,7 +68,7 @@ func FindOrCreateTag(db *gorm.DB, nameZh, nameEn string) (*Tag, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create new tag
|
||||
// 创建新标签
|
||||
tag = Tag{
|
||||
NameZh: nameZh,
|
||||
NameEn: nameEn,
|
||||
@@ -83,14 +83,14 @@ func FindOrCreateTag(db *gorm.DB, nameZh, nameEn string) (*Tag, error) {
|
||||
return &tag, nil
|
||||
}
|
||||
|
||||
// GetAllTags returns all tags ordered by count descending.
|
||||
// GetAllTags 按数量降序返回所有标签。
|
||||
func GetAllTags(db *gorm.DB) ([]Tag, error) {
|
||||
var tags []Tag
|
||||
err := db.Order("count DESC, name_zh ASC").Find(&tags).Error
|
||||
return tags, err
|
||||
}
|
||||
|
||||
// GetTagBySlug returns a tag by its slug.
|
||||
// GetTagBySlug 根据 slug 返回标签。
|
||||
func GetTagBySlug(db *gorm.DB, slug string) (*Tag, error) {
|
||||
var tag Tag
|
||||
err := db.Where("slug = ?", slug).First(&tag).Error
|
||||
@@ -100,22 +100,22 @@ func GetTagBySlug(db *gorm.DB, slug string) (*Tag, error) {
|
||||
return &tag, nil
|
||||
}
|
||||
|
||||
// UpdateTagCount recalculates the article count for a tag.
|
||||
// UpdateTagCount 重新计算某个标签的文章数量。
|
||||
func UpdateTagCount(db *gorm.DB, tagID uint) error {
|
||||
var count int64
|
||||
db.Table("article_tags").Where("tag_id = ?", tagID).Count(&count)
|
||||
return db.Model(&Tag{}).Where("id = ?", tagID).Update("count", count).Error
|
||||
}
|
||||
|
||||
// UpdateAllTagCounts recalculates article counts for all tags.
|
||||
// UpdateAllTagCounts 重新计算所有标签的文章数量。
|
||||
func UpdateAllTagCounts(db *gorm.DB) error {
|
||||
// Get all tags
|
||||
// 获取所有标签
|
||||
var tags []Tag
|
||||
if err := db.Find(&tags).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update count for each tag
|
||||
// 为每个标签更新数量
|
||||
for _, tag := range tags {
|
||||
if err := UpdateTagCount(db, tag.ID); err != nil {
|
||||
return err
|
||||
|
||||
+25
-25
@@ -2,7 +2,7 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// UploadCategory groups file types for the admin UI.
|
||||
// UploadCategory 为管理界面按类别归类文件类型。
|
||||
const (
|
||||
CategoryImage = "image"
|
||||
CategoryDocument = "document"
|
||||
@@ -11,42 +11,42 @@ const (
|
||||
CategoryOther = "other"
|
||||
)
|
||||
|
||||
// DefaultUploadMaxSize is the default per-file size limit (10 MiB), in bytes.
|
||||
// DefaultUploadMaxSize 是默认的单文件大小上限(10 MiB),单位为字节。
|
||||
const DefaultUploadMaxSize int64 = 10 * 1024 * 1024
|
||||
|
||||
// UploadConfig holds the singleton (id=1) global attachment upload policy.
|
||||
// UploadConfig 保存单例(id=1)的全局附件上传策略。
|
||||
type UploadConfig struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"` // master switch for attachment uploads
|
||||
DefaultMaxSize int64 `gorm:"default:10485760" json:"default_max_size"` // bytes; overridden per type by UploadFileType.MaxSize
|
||||
StorageDir string `gorm:"size:255;default:attachments" json:"storage_dir"` // sub-dir under cfg.Path
|
||||
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"` // 附件上传总开关
|
||||
DefaultMaxSize int64 `gorm:"default:10485760" json:"default_max_size"` // 字节;可被 UploadFileType.MaxSize 按类型覆盖
|
||||
StorageDir string `gorm:"size:255;default:attachments" json:"storage_dir"` // cfg.Path 下的子目录
|
||||
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (UploadConfig) TableName() string {
|
||||
return "upload_configs"
|
||||
}
|
||||
|
||||
// UploadFileType describes one permitted attachment extension. Multiple rows.
|
||||
// UploadFileType 描述一种允许的附件扩展名。允许多行记录。
|
||||
type UploadFileType struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Extension string `gorm:"size:32;uniqueIndex" json:"extension"` // with leading dot, e.g. ".pdf"
|
||||
MimeType string `gorm:"size:128" json:"mime_type"` // associated MIME for validation
|
||||
Category string `gorm:"size:32;index" json:"category"` // image/document/archive/video/other
|
||||
MaxSize int64 `gorm:"default:0" json:"max_size"` // bytes; 0 means use UploadConfig.DefaultMaxSize
|
||||
Extension string `gorm:"size:32;uniqueIndex" json:"extension"` // 带前导点,如 ".pdf"
|
||||
MimeType string `gorm:"size:128" json:"mime_type"` // 用于校验的关联 MIME
|
||||
Category string `gorm:"size:32;index" json:"category"` // image/document/archive/video/other
|
||||
MaxSize int64 `gorm:"default:0" json:"max_size"` // 字节;0 表示使用 UploadConfig.DefaultMaxSize
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
Sort int `gorm:"default:0" json:"sort"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (UploadFileType) TableName() string {
|
||||
return "upload_file_types"
|
||||
}
|
||||
|
||||
// EffectiveMaxSize returns the per-file size limit for this type, falling back
|
||||
// to the provided default when MaxSize is 0.
|
||||
// EffectiveMaxSize 返回该类型下单个文件的大小上限,当 MaxSize 为 0 时
|
||||
// 回退到传入的默认值。
|
||||
func (t *UploadFileType) EffectiveMaxSize(def int64) int64 {
|
||||
if t.MaxSize > 0 {
|
||||
return t.MaxSize
|
||||
@@ -54,21 +54,21 @@ func (t *UploadFileType) EffectiveMaxSize(def int64) int64 {
|
||||
return def
|
||||
}
|
||||
|
||||
// DownloadBaseURL is one source base URL used to build attachment download
|
||||
// links. Multiple rows; the row marked IsDefault (or the highest-priority
|
||||
// enabled one) is used for generated links.
|
||||
// DownloadBaseURL 是一种用于构建附件下载链接的来源基础 URL。
|
||||
// 允许多行记录;标记为 IsDefault(或优先级最高且启用)的行
|
||||
// 用于生成链接。
|
||||
type DownloadBaseURL struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Name string `gorm:"size:64" json:"name"` // label, e.g. "主站" / "CDN"
|
||||
BaseURL string `gorm:"size:512" json:"base_url"` // e.g. https://cdn.example.com/uploads
|
||||
Priority int `gorm:"default:0" json:"priority"` // lower = higher priority
|
||||
Name string `gorm:"size:64" json:"name"` // 标签,如 "主站" / "CDN"
|
||||
BaseURL string `gorm:"size:512" json:"base_url"` // 例如 https://cdn.example.com/uploads
|
||||
Priority int `gorm:"default:0" json:"priority"` // 数值越小优先级越高
|
||||
IsDefault bool `gorm:"default:false" json:"is_default"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (DownloadBaseURL) TableName() string {
|
||||
return "download_baseurls"
|
||||
}
|
||||
+11
-6
@@ -7,7 +7,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Account status constants.
|
||||
// 账号状态常量。
|
||||
const (
|
||||
StatusDisabled = 0 // 禁用
|
||||
StatusNormal = 1 // 正常
|
||||
@@ -15,13 +15,13 @@ const (
|
||||
StatusUnactivated = 3 // 未激活
|
||||
)
|
||||
|
||||
// Role constants.
|
||||
// 角色常量。
|
||||
const (
|
||||
RoleAdmin = "admin"
|
||||
RoleAuthor = "author"
|
||||
)
|
||||
|
||||
// User represents a blog user (author / admin).
|
||||
// User 表示博客用户(作者 / 管理员)。
|
||||
type User struct {
|
||||
gorm.Model
|
||||
Username string `gorm:"uniqueIndex;not null;size:255" json:"username"`
|
||||
@@ -36,9 +36,14 @@ type User struct {
|
||||
Articles []Article `gorm:"foreignKey:AuthorID" json:"-"`
|
||||
}
|
||||
|
||||
// SetPassword hashes the plain-text password with bcrypt and stores it.
|
||||
// bcryptCost 是新密码哈希时使用的工作因子(SECURITY_TODO #17)。
|
||||
// 现有哈希保留其原有成本——CompareHashAndPassword 会按哈希自适应——
|
||||
// 并在用户下次修改密码时自然升级。
|
||||
const bcryptCost = 12
|
||||
|
||||
// SetPassword 使用 bcrypt 对明文密码进行哈希并存储。
|
||||
func (u *User) SetPassword(plain string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -46,7 +51,7 @@ func (u *User) SetPassword(plain string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckPassword compares a plain-text password against the stored bcrypt hash.
|
||||
// CheckPassword 将明文密码与存储的 bcrypt 哈希进行比对。
|
||||
func (u *User) CheckPassword(plain string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(plain))
|
||||
return err == nil
|
||||
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build Tailwind CSS from the HTML templates into static/css/app.css.
|
||||
#
|
||||
# The production build does NOT use cdn.tailwindcss.com (the dev runtime):
|
||||
# it is a JS script executed in the browser, which browsers cannot pin with
|
||||
# SRI and which would keep a third-party origin in our CSP
|
||||
# (SECURITY_TODO #9). This script produces a static stylesheet instead.
|
||||
#
|
||||
# Running it requires Node >= 18 with npx available. The generated
|
||||
# static/css/app.css MUST be committed so deployments need no toolchain
|
||||
# (static assets are go:embed'd into the binary).
|
||||
#
|
||||
# Usage: ./scripts/build_tailwind.sh [version]
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-3.4.17}"
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(dirname "${HERE}")"
|
||||
|
||||
echo "==> Building Tailwind CSS ${VERSION} (offline static build)"
|
||||
cd "${ROOT}"
|
||||
# Content sources: templates for static markup, Go sources for class strings
|
||||
# assembled in handlers (e.g. status/role badges), plus the embedding main.
|
||||
npx --yes "tailwindcss@${VERSION}" \
|
||||
-i ./static/css/input.css \
|
||||
-o ./static/css/app.css \
|
||||
--content "./templates/**/*.html" "./handlers/**/*.go" "./middleware/**/*.go" "./main.go"
|
||||
echo "==> Done: static/css/app.css"
|
||||
echo " (commit this file; it is embedded into the binary via go:embed)"
|
||||
+1915
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/*!
|
||||
* Cropper.js v1.6.2
|
||||
* https://fengyuanchen.github.io/cropperjs
|
||||
*
|
||||
* Copyright 2015-present Chen Fengyuan
|
||||
* Released under the MIT license
|
||||
*
|
||||
* Date: 2024-04-21T07:43:02.731Z
|
||||
*/.cropper-container{-webkit-touch-callout:none;direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}
|
||||
Vendored
+10
File diff suppressed because one or more lines are too long
Vendored
+7
File diff suppressed because one or more lines are too long
Vendored
+7
File diff suppressed because one or more lines are too long
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||
Theme: GitHub
|
||||
Description: Light theme as seen on github.com
|
||||
Author: github.com
|
||||
Maintainer: @Hirse
|
||||
Updated: 2021-05-15
|
||||
|
||||
Outdated base version: https://github.com/primer/github-syntax-light
|
||||
Current colors taken from GitHub's CSS
|
||||
*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}
|
||||
Vendored
+1262
File diff suppressed because one or more lines are too long
Vendored
+69
File diff suppressed because one or more lines are too long
Vendored
+3
File diff suppressed because one or more lines are too long
@@ -5,13 +5,12 @@
|
||||
<section class="max-w-3xl mx-auto px-4 py-10">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-8">{{.FormTitleText}}</h2>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm">
|
||||
<div id="articleError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm {{if not .Error}}hidden{{end}}">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="{{.FormAction}}" method="post" class="space-y-6">
|
||||
<form id="articleForm" action="{{.FormAction}}" method="post" class="space-y-6">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<!-- Hidden: attachment ownership (token on create, id on edit) -->
|
||||
<input type="hidden" name="session_token" value="{{.SessionToken}}">
|
||||
|
||||
@@ -142,6 +141,8 @@ var easyMDE = new EasyMDE({
|
||||
(function () {
|
||||
var articleID = {{ if .FormArticleID }}{{ .FormArticleID }}{{ else }}0{{ end }};
|
||||
var sessionToken = "{{ .SessionToken }}";
|
||||
var csrfTokenEl = document.querySelector('meta[name="csrf-token"]');
|
||||
var csrfToken = csrfTokenEl ? csrfTokenEl.getAttribute('content') : '';
|
||||
var uploadBtn = document.getElementById('attachmentUploadBtn');
|
||||
var fileInput = document.getElementById('attachmentInput');
|
||||
var msgEl = document.getElementById('attachmentMsg');
|
||||
@@ -203,7 +204,10 @@ var easyMDE = new EasyMDE({
|
||||
delBtn.className = 'text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none';
|
||||
delBtn.onclick = function () {
|
||||
if (!confirm("{{index .Tr "article_att_delete_confirm"}}")) return;
|
||||
fetch('/admin/articles/attachments/' + att.id + '/delete', { method: 'POST' })
|
||||
fetch('/api/admin/articles/attachments/' + att.id, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrfToken }
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (r.ok) { tr.remove(); }
|
||||
@@ -226,7 +230,11 @@ var easyMDE = new EasyMDE({
|
||||
if (articleID) { fd.append('article_id', articleID); }
|
||||
else { fd.append('session_token', sessionToken); }
|
||||
msgEl.textContent = "{{index .Tr "article_att_uploading"}}";
|
||||
fetch('/admin/articles/attachments', { method: 'POST', body: fd })
|
||||
fetch('/api/admin/articles/attachments', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
body: fd
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (r.error) { msgEl.textContent = r.error; return; }
|
||||
@@ -239,13 +247,32 @@ var easyMDE = new EasyMDE({
|
||||
|
||||
// Edit page: load existing attachments.
|
||||
if (articleID) {
|
||||
fetch('/admin/articles/' + articleID + '/attachments')
|
||||
fetch('/api/admin/articles/' + articleID + '/attachments')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
(r.attachments || []).forEach(addRow);
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
// ---- Article form submit(JSON API;草稿/发布按钮由 e.submitter 分流) ----
|
||||
(function () {
|
||||
var form = document.getElementById('articleForm');
|
||||
if (!form) return;
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var btn = e.submitter || null;
|
||||
// EasyMDE 隐藏了 textarea:先同步编辑器内容再提交。
|
||||
var ta = document.getElementById('articleContent');
|
||||
if (ta && easyMDE) { ta.value = easyMDE.value(); }
|
||||
var method = articleID ? 'PUT' : 'POST';
|
||||
var url = articleID ? '/api/admin/articles/' + articleID : '/api/admin/articles';
|
||||
blogAPI(method, url, blogForm(form, btn)).then(function (r) {
|
||||
if (r.ok) { window.location.href = r.redirect || '/admin'; }
|
||||
else { blogShowError('articleError', r.error || 'Failed to save article.'); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{end}}
|
||||
@@ -40,8 +40,10 @@
|
||||
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
|
||||
<a href="/admin/articles/{{.ID}}/edit"
|
||||
class="text-blue-600 hover:text-blue-800 font-medium mr-3">{{index $.Tr "article_edit"}}</a>
|
||||
<form action="/admin/articles/{{.ID}}/delete" method="post" class="inline"
|
||||
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
|
||||
<form class="inline blog-delete-form"
|
||||
data-url="/api/admin/articles/{{.ID}}"
|
||||
data-confirm="{{index $.Tr "article_delete_confirm"}}"
|
||||
onsubmit="return blogDelete(this)">
|
||||
<button type="submit"
|
||||
class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "article_delete"}}</button>
|
||||
</form>
|
||||
|
||||
@@ -35,7 +35,11 @@
|
||||
{{range .Comments}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
{{if .GravatarURL}}
|
||||
<img src="{{.GravatarURL}}" alt="" class="w-10 h-10 rounded-full bg-gray-100">
|
||||
{{else}}
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold" style="background:{{.AvatarColor}}">{{.Initial}}</div>
|
||||
{{end}}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span class="font-semibold text-gray-800">{{.AuthorName}}</span>
|
||||
@@ -57,17 +61,17 @@
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 mt-3 text-sm">
|
||||
{{if eq .Status 0}}
|
||||
<form action="/admin/comments/{{.ID}}/approve" method="post" class="inline">
|
||||
<form class="comment-act inline" data-id="{{.ID}}" data-act="approve" onsubmit="return commentAct(this)">
|
||||
<button type="submit" class="text-green-600 hover:text-green-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "comment_approve"}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if ne .Status 2}}
|
||||
<form action="/admin/comments/{{.ID}}/reject" method="post" class="inline">
|
||||
<form class="comment-act inline" data-id="{{.ID}}" data-act="reject" onsubmit="return commentAct(this)">
|
||||
<button type="submit" class="text-orange-600 hover:text-orange-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "comment_reject"}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form action="/admin/comments/{{.ID}}/delete" method="post" class="inline"
|
||||
onsubmit="return confirm('{{index $.Tr "comment_delete_confirm"}}');">
|
||||
<form class="comment-act inline" data-id="{{.ID}}" data-act="delete"
|
||||
data-confirm="{{index $.Tr "comment_delete_confirm"}}" onsubmit="return commentAct(this)">
|
||||
<button type="submit" class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "comment_delete"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -83,9 +87,27 @@
|
||||
<script>
|
||||
(function () {
|
||||
document.querySelectorAll('.comment-body[data-md]').forEach(function (el) {
|
||||
BlogMD.renderInto(el, el.getAttribute('data-md') || '');
|
||||
var raw = el.getAttribute('data-md') || '';
|
||||
if (window.BlogMD) {
|
||||
BlogMD.renderInto(el, raw);
|
||||
} else {
|
||||
el.textContent = raw;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// 评论审核操作(approve/reject/delete)走 JSON API;成功 reload 保持筛选状态。
|
||||
window.commentAct = function (form) {
|
||||
var act = form.getAttribute('data-act');
|
||||
var id = form.getAttribute('data-id');
|
||||
var confirmText = form.getAttribute('data-confirm');
|
||||
if (confirmText && !confirm(confirmText)) return false;
|
||||
blogAPI('POST', '/api/admin/comments/' + id + '/' + act).then(function (r) {
|
||||
if (r.ok) { window.location.reload(); }
|
||||
else { alert(r.error || 'Failed'); }
|
||||
});
|
||||
return false;
|
||||
};
|
||||
</script>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -6,7 +6,8 @@
|
||||
<h2 class="text-3xl font-bold text-gray-900">{{index .Tr "dash_title"}}</h2>
|
||||
<p class="text-gray-500 mt-1">{{index .Tr "dash_welcome"}} <span class="font-medium text-gray-700">{{.Username}}</span>!</p>
|
||||
</div>
|
||||
<form action="/logout" method="post" class="m-0">
|
||||
<form action="/logout" method="post" class="m-0 logout-form">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-gray-200 text-gray-700 px-4 py-2 rounded-lg font-medium hover:bg-gray-300 transition-colors cursor-pointer"
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg mb-6">{{.Success}}</div>
|
||||
{{end}}
|
||||
|
||||
<form action="/admin/settings/comments" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
|
||||
<form class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4"
|
||||
data-api-url="/api/admin/settings/comments" onsubmit="return blogSettingsForm(this)">
|
||||
<label class="flex items-center gap-3 text-sm text-gray-700">
|
||||
<input type="checkbox" name="enabled" value="1" {{if .CommentConfig.Enabled}}checked{{end}}
|
||||
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
|
||||
@@ -41,19 +41,18 @@
|
||||
{{else}}<span class="text-xs bg-gray-200 text-gray-500 px-2 py-1 rounded">—</span>{{end}}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
|
||||
<form action="/admin/settings/download" method="post" class="inline">
|
||||
<input type="hidden" name="action" value="default">
|
||||
<form class="inline" data-api-url="/api/admin/settings/download"
|
||||
data-action="default" onsubmit="return blogSettingsForm(this)">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="text-blue-600 hover:text-blue-800 font-medium mr-3 cursor-pointer bg-transparent border-none">{{index $.Tr "settings_set_default"}}</button>
|
||||
</form>
|
||||
<form action="/admin/settings/download" method="post" class="inline">
|
||||
<input type="hidden" name="action" value="toggle">
|
||||
<form class="inline" data-api-url="/api/admin/settings/download"
|
||||
data-action="toggle" onsubmit="return blogSettingsForm(this)">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="text-gray-600 hover:text-gray-800 font-medium mr-3 cursor-pointer bg-transparent border-none">{{index $.Tr "settings_toggle"}}</button>
|
||||
</form>
|
||||
<form action="/admin/settings/download" method="post" class="inline"
|
||||
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<form class="inline" data-api-url="/api/admin/settings/download" data-action="delete"
|
||||
data-confirm="{{index $.Tr "article_delete_confirm"}}" onsubmit="return blogSettingsForm(this)">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "settings_delete"}}</button>
|
||||
</form>
|
||||
@@ -69,8 +68,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Add base URL -->
|
||||
<form action="/admin/settings/download" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<form class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"
|
||||
data-api-url="/api/admin/settings/download" data-action="add" onsubmit="return blogSettingsForm(this)">
|
||||
<h3 class="font-semibold text-gray-800 mb-4">{{index .Tr "settings_add_url"}}</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
{{end}}
|
||||
|
||||
<!-- Add New Link Form -->
|
||||
<form action="/admin/settings/navlinks" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<form class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6"
|
||||
data-api-url="/api/admin/settings/navlinks" data-action="add" onsubmit="return blogSettingsForm(this)">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{index .Tr "navlinks_add"}}</h3>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
|
||||
@@ -90,8 +90,7 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Toggle Button -->
|
||||
<form action="/admin/settings/navlinks" method="post" class="inline">
|
||||
<input type="hidden" name="action" value="toggle">
|
||||
<form class="inline" data-api-url="/api/admin/settings/navlinks" data-action="toggle" onsubmit="return blogSettingsForm(this)">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="text-sm px-3 py-1.5 rounded-lg border {{if .Enabled}}bg-green-50 border-green-300 text-green-700 hover:bg-green-100{{else}}bg-gray-100 border-gray-300 text-gray-600 hover:bg-gray-200{{end}} transition-colors">
|
||||
{{if .Enabled}}✓{{else}}✗{{end}} {{index $.Tr "settings_toggle"}}
|
||||
@@ -102,8 +101,8 @@
|
||||
{{index $.Tr "navlinks_edit"}}
|
||||
</button>
|
||||
<!-- Delete Button -->
|
||||
<form action="/admin/settings/navlinks" method="post" class="inline" onsubmit="return confirm('{{index $.Tr "navlinks_confirm_delete"}}')">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<form class="inline" data-api-url="/api/admin/settings/navlinks" data-action="delete"
|
||||
data-confirm="{{index $.Tr "navlinks_confirm_delete"}}" onsubmit="return blogSettingsForm(this)">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="text-sm px-3 py-1.5 bg-red-50 border border-red-300 text-red-700 rounded-lg hover:bg-red-100 transition-colors">
|
||||
{{index $.Tr "navlinks_delete"}}
|
||||
@@ -124,8 +123,7 @@
|
||||
<div id="editModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 p-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-4">{{index .Tr "navlinks_edit"}}</h3>
|
||||
<form action="/admin/settings/navlinks" method="post" id="editForm">
|
||||
<input type="hidden" name="action" value="edit">
|
||||
<form id="editForm" data-api-url="/api/admin/settings/navlinks" data-action="edit" onsubmit="return blogSettingsForm(this)">
|
||||
<input type="hidden" name="id" id="edit_id">
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg mb-6">{{.Success}}</div>
|
||||
{{end}}
|
||||
|
||||
<form action="/admin/settings/site" method="post" enctype="multipart/form-data" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
|
||||
<div id="siteSettingsError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 hidden"></div>
|
||||
|
||||
<form id="siteSettingsForm" action="/api/admin/settings/site" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
|
||||
<!-- Logo -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">{{index .Tr "settings_logo"}}</label>
|
||||
@@ -28,7 +30,7 @@
|
||||
{{end}}
|
||||
<input type="text" name="logo_url" placeholder="{{index .Tr "settings_logo_url"}}"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 mb-2" value="{{if .SiteLogoIsURL}}{{.Site.Logo}}{{end}}">
|
||||
<input type="file" name="logo" accept="image/*"
|
||||
<input type="file" id="logoFileInput" name="logo" accept="image/*"
|
||||
class="block w-full text-sm text-gray-500 mb-2">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-600">
|
||||
<input type="checkbox" name="logo_clear" value="1"> {{index .Tr "settings_logo_clear"}}
|
||||
@@ -50,7 +52,7 @@
|
||||
{{end}}
|
||||
<input type="text" name="favicon_url" placeholder="{{index .Tr "settings_favicon_url"}}"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 mb-2" value="{{if .SiteFaviconIsURL}}{{.Site.Favicon}}{{end}}">
|
||||
<input type="file" name="favicon" accept="image/x-icon,image/png,image/svg+xml"
|
||||
<input type="file" id="faviconFileInput" name="favicon" accept="image/x-icon,image/png,image/svg+xml"
|
||||
class="block w-full text-sm text-gray-500 mb-2">
|
||||
<p class="text-xs text-gray-500 mb-2">{{index .Tr "settings_favicon_hint"}}</p>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-600">
|
||||
@@ -128,6 +130,14 @@
|
||||
|
||||
<p class="text-xs text-gray-400">{{index .Tr "settings_leave_blank"}}</p>
|
||||
|
||||
<!-- Canonical site URL (RSS) -->
|
||||
<div class="pt-4 border-t border-gray-200">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_site_url"}}</label>
|
||||
<input type="url" name="site_url" value="{{.Site.SiteURL}}" placeholder="https://example.com"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||
<p class="text-xs text-gray-500 mt-1">{{index .Tr "settings_site_url_hint"}}</p>
|
||||
</div>
|
||||
|
||||
<!-- Registration settings -->
|
||||
<div class="pt-4 border-t border-gray-200">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
@@ -147,4 +157,47 @@
|
||||
</form>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
|
||||
<script>
|
||||
// 站点设置主表单:文本字段走 JSON API。
|
||||
(function () {
|
||||
var form = document.getElementById('siteSettingsForm');
|
||||
if (!form) return;
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var btn = e.submitter || null;
|
||||
blogAPI('POST', '/api/admin/settings/site', blogForm(form, btn)).then(function (r) {
|
||||
if (r.ok) { window.location.href = r.redirect || '/admin/settings/site'; }
|
||||
else { blogShowError('siteSettingsError', r.error || 'Failed to save settings.'); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// logo / favicon 文件选择即上传(multipart)。
|
||||
(function () {
|
||||
function bindImageUpload(inputId, url, field) {
|
||||
var input = document.getElementById(inputId);
|
||||
if (!input) return;
|
||||
input.addEventListener('change', function () {
|
||||
var file = this.files[0];
|
||||
if (!file) return;
|
||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||
var fd = new FormData();
|
||||
fd.append(field, file);
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': meta ? meta.getAttribute('content') : '' },
|
||||
body: fd,
|
||||
credentials: 'same-origin'
|
||||
}).then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (r.ok) { window.location.href = r.redirect || '/admin/settings/site'; }
|
||||
else { blogShowError('siteSettingsError', r.error || 'Upload failed.'); }
|
||||
});
|
||||
});
|
||||
}
|
||||
bindImageUpload('logoFileInput', '/api/admin/settings/site/logo', 'logo');
|
||||
bindImageUpload('faviconFileInput', '/api/admin/settings/site/favicon', 'favicon');
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -14,10 +14,13 @@
|
||||
{{if .Success}}
|
||||
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg mb-6">{{.Success}}</div>
|
||||
{{end}}
|
||||
{{if .Error}}
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Global policy -->
|
||||
<form action="/admin/settings/upload" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8">
|
||||
<input type="hidden" name="action" value="save_config">
|
||||
<form class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8"
|
||||
data-api-url="/api/admin/settings/upload" data-action="save_config" onsubmit="return blogSettingsForm(this)">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">{{index .Tr "settings_default_size"}}</label>
|
||||
@@ -65,8 +68,8 @@
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{.MimeType}}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{index $.Tr (printf "cat_%s" .Category)}}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">
|
||||
<form action="/admin/settings/upload" method="post" class="flex items-center gap-1">
|
||||
<input type="hidden" name="action" value="size_type">
|
||||
<form class="flex items-center gap-1" data-api-url="/api/admin/settings/upload"
|
||||
data-action="size_type" onsubmit="return blogSettingsForm(this)">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<input type="number" name="max_size" min="0" step="0.1" value="{{.MaxSizeMB}}"
|
||||
class="w-20 border border-gray-300 rounded px-2 py-1 text-sm">
|
||||
@@ -78,14 +81,13 @@
|
||||
{{else}}<span class="text-xs bg-gray-200 text-gray-500 px-2 py-1 rounded">—</span>{{end}}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
|
||||
<form action="/admin/settings/upload" method="post" class="inline">
|
||||
<input type="hidden" name="action" value="toggle_type">
|
||||
<form class="inline" data-api-url="/api/admin/settings/upload"
|
||||
data-action="toggle_type" onsubmit="return blogSettingsForm(this)">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="text-gray-600 hover:text-gray-800 font-medium mr-3 cursor-pointer bg-transparent border-none">{{index $.Tr "settings_toggle"}}</button>
|
||||
</form>
|
||||
<form action="/admin/settings/upload" method="post" class="inline"
|
||||
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
|
||||
<input type="hidden" name="action" value="delete_type">
|
||||
<form class="inline" data-api-url="/api/admin/settings/upload" data-action="delete_type"
|
||||
data-confirm="{{index $.Tr "article_delete_confirm"}}" onsubmit="return blogSettingsForm(this)">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button type="submit" class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "settings_delete"}}</button>
|
||||
</form>
|
||||
@@ -97,8 +99,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Add file type -->
|
||||
<form action="/admin/settings/upload" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<input type="hidden" name="action" value="add_type">
|
||||
<form class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"
|
||||
data-api-url="/api/admin/settings/upload" data-action="add_type" onsubmit="return blogSettingsForm(this)">
|
||||
<h3 class="font-semibold text-gray-800 mb-4">{{index .Tr "settings_add_type"}}</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-4">
|
||||
<div>
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
<section class="max-w-3xl mx-auto px-4 py-10">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-8">{{.FormTitleText}}</h2>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm">
|
||||
<div id="userFormError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm {{if not .Error}}hidden{{end}}">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="{{.FormAction}}" method="post" class="space-y-6">
|
||||
<form id="userForm" action="{{.FormAction}}" method="post" class="space-y-6">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<!-- Username (read-only on edit) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_username"}}</label>
|
||||
@@ -102,4 +101,22 @@
|
||||
</form>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var form = document.getElementById('userForm');
|
||||
if (!form) return;
|
||||
var userId = {{ .FormID }};
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var btn = e.submitter || null;
|
||||
var method = userId ? 'PUT' : 'POST';
|
||||
var url = userId ? '/api/admin/users/' + userId : '/api/admin/users';
|
||||
blogAPI(method, url, blogForm(form, btn)).then(function (r) {
|
||||
if (r.ok) { window.location.href = r.redirect || '/admin/users'; }
|
||||
else { blogShowError('userFormError', r.error || 'Failed to save user.'); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -59,8 +59,10 @@
|
||||
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
|
||||
<a href="/admin/users/{{.ID}}/edit"
|
||||
class="text-blue-600 hover:text-blue-800 font-medium mr-3">{{index $.Tr "article_edit"}}</a>
|
||||
<form action="/admin/users/{{.ID}}/delete" method="post" class="inline"
|
||||
onsubmit="return confirm('{{index $.Tr "user_delete_confirm"}}');">
|
||||
<form class="inline blog-delete-form"
|
||||
data-url="/api/admin/users/{{.ID}}"
|
||||
data-confirm="{{index $.Tr "user_delete_confirm"}}"
|
||||
onsubmit="return blogDelete(this)">
|
||||
<button type="submit"
|
||||
class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "settings_delete"}}</button>
|
||||
</form>
|
||||
|
||||
+116
-13
@@ -4,6 +4,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{.CSRFToken}}">
|
||||
<title>{{.Title}} - {{index .Tr "site_title"}}</title>
|
||||
{{if .SiteFavicon}}
|
||||
{{if .SiteFaviconIsURL}}
|
||||
@@ -13,11 +14,13 @@
|
||||
{{end}}
|
||||
{{end}}
|
||||
<link rel="alternate" type="application/rss+xml" title="RSS Feed" href="/rss">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
|
||||
<!-- All third-party assets are vendored locally (static/vendor, embedded
|
||||
via go:embed) — no external CDN origins, see SECURITY_TODO #9. -->
|
||||
<link rel="stylesheet" href="/static/css/app.css?v=1">
|
||||
<link rel="stylesheet" href="/static/vendor/cropper.min.css?v=1">
|
||||
<script src="/static/vendor/cropper.min.js?v=1"></script>
|
||||
<link rel="stylesheet" href="/static/vendor/easymde.min.css?v=1">
|
||||
<script src="/static/vendor/easymde.min.js?v=1"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||
<!-- Navigation -->
|
||||
@@ -79,7 +82,8 @@
|
||||
</a>
|
||||
{{end}}
|
||||
<div class="border-t border-gray-100 my-1"></div>
|
||||
<form action="/logout" method="post" class="m-0">
|
||||
<form action="/logout" method="post" class="m-0 logout-form">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="w-full text-left block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors cursor-pointer bg-transparent border-none">
|
||||
{{index .Tr "logout"}}
|
||||
</button>
|
||||
@@ -111,13 +115,16 @@
|
||||
{{define "markdown_assets"}}
|
||||
{{/* Markdown rendering assets: marked (pinned UMD build), DOMPurify,
|
||||
highlight.js and the shared renderer + styles. Include on any page that
|
||||
renders Markdown (articles, comments, editor previews). */}}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/styles/github.min.css">
|
||||
<link rel="stylesheet" href="/static/css/markdown.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@15.0.12/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.4.13/dist/purify.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/highlight.min.js"></script>
|
||||
<script src="/static/js/markdown.js"></script>
|
||||
renders Markdown (articles, comments, editor previews).
|
||||
Local static files carry a ?v= cache-buster: bump it whenever
|
||||
static/js/markdown.js or static/css/markdown.css changes, otherwise
|
||||
browsers may keep serving stale cached copies. */}}
|
||||
<link rel="stylesheet" href="/static/vendor/github.min.css?v=1">
|
||||
<link rel="stylesheet" href="/static/css/markdown.css?v=2">
|
||||
<script src="/static/vendor/marked.min.js?v=1"></script>
|
||||
<script src="/static/vendor/purify.min.js?v=1"></script>
|
||||
<script src="/static/vendor/highlight.min.js?v=1"></script>
|
||||
<script src="/static/js/markdown.js?v=2"></script>
|
||||
{{end}}
|
||||
|
||||
{{define "footer"}}
|
||||
@@ -166,9 +173,105 @@
|
||||
<div class="text-center text-gray-500 text-xs">
|
||||
{{if .SiteFooterText}}{{.SiteFooterText}}{{else}}{{index .Tr "footer_text"}}{{end}}
|
||||
</div>
|
||||
<div class="text-center text-gray-400 text-[10px] mt-1">
|
||||
{{.BuildInfo}}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script>
|
||||
// ---- Shared API helpers(/api/* JSON 接口统一入口) ----
|
||||
window.blogAPI = function (method, url, body) {
|
||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||
var csrf = meta ? meta.getAttribute('content') : '';
|
||||
var opts = {
|
||||
method: method,
|
||||
headers: {
|
||||
'X-CSRF-Token': csrf,
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
};
|
||||
if (body !== undefined) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
return fetch(url, opts).then(function (r) {
|
||||
return r.json().catch(function () {
|
||||
return { ok: false, code: 'api_error', error: r.statusText };
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 将表单序列化为 JSON 数据对象:
|
||||
// - 文本/select/textarea 从 FormData 取值(checkboxes 在下述循环覆盖)
|
||||
// - checkbox 一律转 bool(未选中也发送 false,匹配服务端 JSON 绑定)
|
||||
// - file 字段剔除(文件上传走单独的 multipart 端点)
|
||||
// - 提交按钮(如文章表单的草稿/发布 name=status)取自 submitter
|
||||
window.blogForm = function (form, submitter) {
|
||||
var data = {};
|
||||
new FormData(form).forEach(function (v, k) {
|
||||
if (k === '_csrf') return;
|
||||
if (!(k in data)) data[k] = v;
|
||||
});
|
||||
Array.prototype.forEach.call(form.querySelectorAll('input[type="file"]'), function (el) {
|
||||
if (el.name) { data[el.name] = undefined; delete data[el.name]; }
|
||||
});
|
||||
Array.prototype.forEach.call(form.querySelectorAll('input[type="checkbox"]'), function (el) {
|
||||
if (el.name) data[el.name] = el.checked;
|
||||
});
|
||||
if (submitter && submitter.name) {
|
||||
data[submitter.name] = submitter.value;
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
// 在指定错误 div 中显示 API 错误文案。
|
||||
window.blogShowError = function (divId, msg) {
|
||||
var el = document.getElementById(divId);
|
||||
if (el) { el.textContent = msg; el.classList.remove('hidden'); }
|
||||
};
|
||||
|
||||
// Logout forms(class=logout-form)统一改走 JSON API 后跳转首页。
|
||||
document.addEventListener('submit', function (e) {
|
||||
var form = e.target;
|
||||
if (form.classList && form.classList.contains('logout-form')) {
|
||||
e.preventDefault();
|
||||
blogAPI('POST', '/api/auth/logout').then(function (r) {
|
||||
window.location.href = (r && r.redirect) || '/';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 列表删除提交(onsubmit = "return blogDelete(this)"):
|
||||
// DELETE data-url 指向的 JSON 接口,成功后 reload(保留筛选状态)。
|
||||
window.blogDelete = function (form) {
|
||||
var url = form.getAttribute('data-url');
|
||||
var confirmText = form.getAttribute('data-confirm');
|
||||
if (confirmText && !confirm(confirmText)) return false;
|
||||
blogAPI('DELETE', url).then(function (r) {
|
||||
if (r.ok) { window.location.reload(); }
|
||||
else { alert(r.error || 'Failed'); }
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
||||
// 设置页小表单委托(onsubmit = "return blogSettingsForm(this)"):
|
||||
// POST data-api-url,body 为 {action: data-action} + 表单序列化字段,
|
||||
// 成功后跳转 data.redirect(通常刷新列表),失败 alert 文案。
|
||||
window.blogSettingsForm = function (form) {
|
||||
var url = form.getAttribute('data-api-url');
|
||||
var confirmText = form.getAttribute('data-confirm');
|
||||
if (confirmText && !confirm(confirmText)) return false;
|
||||
var data = blogForm(form, null);
|
||||
var action = form.getAttribute('data-action');
|
||||
if (action) data.action = action;
|
||||
blogAPI('POST', url, data).then(function (r) {
|
||||
if (r.ok) { window.location.href = r.redirect || window.location.href; }
|
||||
else { alert(r.error || 'Failed'); }
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
||||
function toggleDropdown() {
|
||||
var menu = document.getElementById('dropdownMenu');
|
||||
menu.classList.toggle('hidden');
|
||||
|
||||
@@ -45,9 +45,7 @@
|
||||
<div id="articleBody" class="prose max-w-none text-gray-800 md-body"></div>
|
||||
</article>
|
||||
|
||||
{{if .CommentError}}
|
||||
<div class="max-w-3xl mx-auto mt-8 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">{{.CommentError}}</div>
|
||||
{{end}}
|
||||
<div id="commentError" class="max-w-3xl mx-auto mt-8 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg {{if not .CommentError}}hidden{{end}}">{{.CommentError}}</div>
|
||||
|
||||
{{if .CommentConfig}}
|
||||
{{if .CommentConfig.Enabled}}
|
||||
@@ -74,7 +72,8 @@
|
||||
<span id="replyTarget"></span>
|
||||
<button type="button" id="cancelReply" class="text-blue-600 hover:text-blue-800 font-medium bg-transparent border-none cursor-pointer">{{index .Tr "comments_cancel_reply"}}</button>
|
||||
</div>
|
||||
<form id="commentForm" action="/article/{{.Article.Slug}}/comments" method="post">
|
||||
<form id="commentForm" action="/api/article/{{.Article.Slug}}/comments" method="post">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="parent_id" id="parent_id" value="{{.CommentForm.ParentID}}">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
@@ -144,12 +143,27 @@
|
||||
<script>
|
||||
// Article body: render Markdown client-side through the shared BlogMD
|
||||
// pipeline (marked + DOMPurify + highlight.js, see /static/js/markdown.js).
|
||||
BlogMD.renderInto(document.getElementById('articleBody'), {{.Article.Content}});
|
||||
// If the renderer failed to load, fall back to plain text so the article
|
||||
// is never blank.
|
||||
(function () {
|
||||
var body = document.getElementById('articleBody');
|
||||
var content = {{.Article.Content}};
|
||||
if (window.BlogMD) {
|
||||
BlogMD.renderInto(body, content);
|
||||
} else {
|
||||
body.textContent = content;
|
||||
}
|
||||
})();
|
||||
|
||||
// Comments are rendered from the escaped data-md attribute.
|
||||
document.querySelectorAll('.comment-body[data-md]').forEach(function (el) {
|
||||
if (el.dataset.rendered) return;
|
||||
BlogMD.renderInto(el, el.getAttribute('data-md') || '');
|
||||
var raw = el.getAttribute('data-md') || '';
|
||||
if (window.BlogMD) {
|
||||
BlogMD.renderInto(el, raw);
|
||||
} else {
|
||||
el.textContent = raw;
|
||||
}
|
||||
el.dataset.rendered = '1';
|
||||
});
|
||||
|
||||
@@ -191,7 +205,11 @@
|
||||
var previewing = previewBox.classList.toggle('hidden') === false;
|
||||
textarea.classList.toggle('hidden', previewing);
|
||||
if (previewing) {
|
||||
previewBox.innerHTML = BlogMD.render(textarea.value);
|
||||
if (window.BlogMD) {
|
||||
previewBox.innerHTML = BlogMD.render(textarea.value);
|
||||
} else {
|
||||
previewBox.textContent = textarea.value;
|
||||
}
|
||||
togglePreview.textContent = '{{index .Tr "comments_edit"}}';
|
||||
} else {
|
||||
togglePreview.textContent = '{{index .Tr "comments_preview"}}';
|
||||
@@ -243,6 +261,22 @@
|
||||
document.getElementById('comments').insertBefore(formWrap, document.getElementById('commentList').nextSibling);
|
||||
});
|
||||
})();
|
||||
|
||||
// Comment submission: JSON API + client-side error display.
|
||||
(function () {
|
||||
var form = document.getElementById('commentForm');
|
||||
if (!form) return;
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var btn = e.submitter || null;
|
||||
if (btn) btn.disabled = true;
|
||||
blogAPI('POST', form.action, blogForm(form, btn)).then(function (r) {
|
||||
if (btn) btn.disabled = false;
|
||||
if (r.ok) { window.location.href = r.redirect || form.action; }
|
||||
else { blogShowError('commentError', r.error || 'Failed to post comment.'); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{template "footer" .}}
|
||||
|
||||
@@ -4,13 +4,12 @@
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">{{index .Tr "login_title"}}</h2>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm">
|
||||
<div id="loginError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm {{if not .Error}}hidden{{end}}">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="/login" method="post" class="space-y-5">
|
||||
<form id="loginForm" action="/login" method="post" class="space-y-5">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "login_username"}}</label>
|
||||
<input
|
||||
@@ -50,4 +49,21 @@
|
||||
</div>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var form = document.getElementById('loginForm');
|
||||
if (!form) return;
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var btn = e.submitter || null;
|
||||
if (btn) btn.disabled = true;
|
||||
blogAPI('POST', '/api/auth/login', blogForm(form, btn)).then(function (r) {
|
||||
if (btn) btn.disabled = false;
|
||||
if (r.ok) { window.location.href = r.redirect || '/'; }
|
||||
else { blogShowError('loginError', r.error || 'Failed to sign in.'); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -35,7 +35,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action="/profile" method="post" enctype="multipart/form-data" class="space-y-8">
|
||||
<div id="profileError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 hidden"></div>
|
||||
|
||||
<form id="profileForm" action="/api/profile" method="post" class="space-y-8">
|
||||
<!-- Avatar Section -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{index .Tr "profile_avatar"}}</h3>
|
||||
@@ -137,6 +139,9 @@
|
||||
var i18nCropSuccess = "{{index .Tr "crop_success"}}";
|
||||
var i18nCropConfirm = "{{index .Tr "crop_confirm"}}";
|
||||
|
||||
var csrfTokenEl = document.querySelector('meta[name="csrf-token"]');
|
||||
var csrfToken = csrfTokenEl ? csrfTokenEl.getAttribute('content') : '';
|
||||
|
||||
fileInput.addEventListener('change', function() {
|
||||
var file = this.files[0];
|
||||
if (!file) return;
|
||||
@@ -191,8 +196,9 @@
|
||||
var formData = new FormData();
|
||||
formData.append('avatar', blob, 'avatar.jpg');
|
||||
|
||||
fetch('/profile/avatar', {
|
||||
fetch('/api/profile/avatar', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
body: formData,
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
@@ -240,6 +246,21 @@
|
||||
else statusDiv.classList.add('text-gray-500');
|
||||
}
|
||||
})();
|
||||
|
||||
// 个人资料主表单:文本字段 + 密码走 JSON API;
|
||||
// 头像文件由 cropper 流程独立上传(/api/profile/avatar)。
|
||||
(function () {
|
||||
var form = document.getElementById('profileForm');
|
||||
if (!form) return;
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var btn = e.submitter || null;
|
||||
blogAPI('POST', '/api/profile', blogForm(form, btn)).then(function (r) {
|
||||
if (r.ok) { window.location.href = r.redirect || '/profile'; }
|
||||
else { blogShowError('profileError', r.error || 'Failed to save profile.'); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{end}}
|
||||
@@ -4,13 +4,12 @@
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">{{index .Tr "register_title"}}</h2>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm">
|
||||
<div id="registerError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm {{if not .Error}}hidden{{end}}">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="/register" method="post" class="space-y-5">
|
||||
<form id="registerForm" action="/register" method="post" class="space-y-5">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "register_username"}}</label>
|
||||
<input
|
||||
@@ -87,4 +86,21 @@
|
||||
</div>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var form = document.getElementById('registerForm');
|
||||
if (!form) return;
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var btn = e.submitter || null;
|
||||
if (btn) btn.disabled = true;
|
||||
blogAPI('POST', '/api/auth/register', blogForm(form, btn)).then(function (r) {
|
||||
if (btn) btn.disabled = false;
|
||||
if (r.ok) { window.location.href = r.redirect || '/'; }
|
||||
else { blogShowError('registerError', r.error || 'Registration failed.'); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -6,13 +6,12 @@
|
||||
<h2 class="text-3xl font-bold text-gray-900">{{.FormTitleText}}</h2>
|
||||
</div>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="mb-6 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
||||
<div id="myArticleError" class="mb-6 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg {{if not .Error}}hidden{{end}}">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="{{.FormAction}}" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
|
||||
<form id="myArticleForm" action="{{.FormAction}}" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
{{if .SessionToken}}
|
||||
<input type="hidden" name="session_token" value="{{.SessionToken}}">
|
||||
{{end}}
|
||||
@@ -65,14 +64,6 @@
|
||||
<option value="1" {{if eq .FormStatus "1"}}selected{{end}}>{{index .Tr "article_published"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="checkbox" name="is_top" value="1" {{if .FormIsTop}}checked{{end}}
|
||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||||
<span class="ml-2 text-sm font-medium text-gray-700">{{index .Tr "article_field_is_top"}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
@@ -89,8 +80,9 @@
|
||||
</section>
|
||||
|
||||
<script>
|
||||
var myEasyMDE = null;
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var easyMDE = new EasyMDE({
|
||||
myEasyMDE = new EasyMDE({
|
||||
element: document.getElementById('content'),
|
||||
spellChecker: false,
|
||||
status: false,
|
||||
@@ -101,6 +93,25 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
"link", "image", "|", "preview", "side-by-side", "fullscreen", "|", "guide"]
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Form submit(JSON API) ----
|
||||
(function () {
|
||||
var form = document.getElementById('myArticleForm');
|
||||
if (!form) return;
|
||||
var articleId = {{ if .FormArticleID }}{{ .FormArticleID }}{{ else }}0{{ end }};
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var btn = e.submitter || null;
|
||||
var ta = document.getElementById('content');
|
||||
if (ta && myEasyMDE) { ta.value = myEasyMDE.value(); }
|
||||
var method = articleId ? 'PUT' : 'POST';
|
||||
var url = articleId ? '/api/my/articles/' + articleId : '/api/my/articles';
|
||||
blogAPI(method, url, blogForm(form, btn)).then(function (r) {
|
||||
if (r.ok) { window.location.href = r.redirect || '/my/articles'; }
|
||||
else { blogShowError('myArticleError', r.error || 'Failed to save article.'); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{template "footer" .}}
|
||||
|
||||
@@ -40,8 +40,10 @@
|
||||
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
|
||||
<a href="/my/articles/{{.ID}}/edit"
|
||||
class="text-blue-600 hover:text-blue-800 font-medium mr-3">{{index $.Tr "article_edit"}}</a>
|
||||
<form action="/my/articles/{{.ID}}/delete" method="post" class="inline"
|
||||
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
|
||||
<form class="inline blog-delete-form"
|
||||
data-url="/api/my/articles/{{.ID}}"
|
||||
data-confirm="{{index $.Tr "article_delete_confirm"}}"
|
||||
onsubmit="return blogDelete(this)">
|
||||
<button type="submit"
|
||||
class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "article_delete"}}</button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user