Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6264aa555 | ||
|
|
cc49152f1b | ||
|
|
a3122b04a4 | ||
|
|
942e3174b9 | ||
|
|
a6c1f92197 | ||
|
|
73412e51b4 | ||
|
|
30af358dd7 | ||
|
|
d78ed8e2fe | ||
|
|
f5439ae93a | ||
|
|
134943e4fb | ||
|
|
6216b9af13 | ||
|
|
39609ebf03 | ||
|
|
970dbd4c5b | ||
|
|
09bf35d0dd | ||
|
|
99ea9427bd | ||
|
|
7280650069 | ||
|
|
6220de9c84 | ||
|
|
9bdb2d8556 | ||
|
|
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 |
@@ -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)。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -50,14 +56,25 @@ go run .
|
||||
| Linux | `/etc/blog_go/config.yaml` | `/srv/blog_go/` |
|
||||
| Windows | `./win/etc/blog_go/config.yaml` | `./win/srv/blog_go/` |
|
||||
|
||||
已存在的配置文件若缺少配置项(例如升级前的旧文件没有 `database` 段),启动时会自动用默认值补齐并写回。
|
||||
|
||||
```yaml
|
||||
# config.yaml
|
||||
database:
|
||||
type: sqlite # sqlite(默认)或 mysql
|
||||
dsn: "" # MySQL 连接串,sqlite 模式下忽略
|
||||
port: "8080" # Web 服务端口
|
||||
db_name: "" # MySQL 数据库名,sqlite 模式下忽略
|
||||
username: "" # MySQL 用户名,sqlite 模式下忽略
|
||||
password: "" # MySQL 密码,sqlite 模式下忽略
|
||||
host: "" # MySQL IP 或主机名,sqlite 模式下忽略
|
||||
port: "" # MySQL 端口,sqlite 模式下忽略
|
||||
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
|
||||
@@ -67,8 +84,13 @@ secret: <自动生成> # Session 加密密钥
|
||||
```yaml
|
||||
database:
|
||||
type: mysql
|
||||
dsn: user:password@tcp(127.0.0.1:3306)/blog_go?charset=utf8mb4&parseTime=True&loc=Local
|
||||
port: "8080"
|
||||
db_name: blog_go
|
||||
username: user
|
||||
password: password
|
||||
host: 127.0.0.1
|
||||
port: "3306"
|
||||
web:
|
||||
port: "8080"
|
||||
```
|
||||
|
||||
先创建数据库:
|
||||
@@ -79,6 +101,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 +136,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,12 +156,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)
|
||||
│ ├── js/markdown.js # 前端 Markdown 渲染器(BlogMD)
|
||||
│ └── vendor/ # 本地化的第三方前端库(marked/DOMPurify/highlight.js/cropperjs/easymde)
|
||||
├── scripts/
|
||||
│ └── build_tailwind.sh # 重新生成 Tailwind CSS(需 Node,见下)
|
||||
├── templates/
|
||||
│ ├── layouts/base.html # 公共布局(导航栏 + 头像下拉菜单 + 页脚)
|
||||
│ ├── pages/
|
||||
@@ -154,9 +198,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 ./...` 全绿。
|
||||
|
||||
## 路由
|
||||
|
||||
### 公开路由
|
||||
@@ -165,57 +234,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)
|
||||
}
|
||||
+158
-42
@@ -1,17 +1,19 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config holds all application configuration.
|
||||
// Config 保存全部应用配置。
|
||||
type Config struct {
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Web WebConfig `yaml:"web"`
|
||||
@@ -19,24 +21,56 @@ 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"
|
||||
DBName string `yaml:"db_name"` // 数据库名(type 为 "mysql" 时必填)
|
||||
Username string `yaml:"username"` // 用户名(type 为 "mysql" 时必填)
|
||||
Password string `yaml:"password"` // 密码(type 为 "mysql" 时必填)
|
||||
Host string `yaml:"host"` // IP 或主机名(type 为 "mysql" 时必填)
|
||||
Port string `yaml:"port"` // 端口(type 为 "mysql" 时必填)
|
||||
}
|
||||
|
||||
// WebConfig holds web-server listening configuration.
|
||||
type WebConfig struct {
|
||||
Port string `yaml:"port"` // TCP port, "" or "0" to disable
|
||||
Socket string `yaml:"socket"` // Unix socket path, "" to disable
|
||||
// MySQLDSN 根据拆分字段构建 MySQL 连接字符串。
|
||||
func (d *DatabaseConfig) MySQLDSN() string {
|
||||
return fmt.Sprintf(
|
||||
"%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
d.Username, d.Password, d.Host, d.Port, d.DBName,
|
||||
)
|
||||
}
|
||||
|
||||
// WebConfig 保存 Web 服务器监听配置。
|
||||
type WebConfig struct {
|
||||
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.
|
||||
const mysqlExampleDSN = "user:password@tcp(127.0.0.1:3306)/blog_go?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
// MySQL 示例值,用于写入新建配置文件或补齐缺失的数据库段。
|
||||
const (
|
||||
mysqlExampleDBName = "blog_go"
|
||||
mysqlExampleUser = "user"
|
||||
mysqlExamplePassword = "password"
|
||||
mysqlExampleHost = "127.0.0.1"
|
||||
mysqlExamplePort = "3306"
|
||||
)
|
||||
|
||||
// getConfigPath returns the OS-aware config directory and config file path.
|
||||
// databaseKeys / webKeys 用于检查配置文件中缺失的子键。
|
||||
var (
|
||||
databaseKeys = []string{"type", "db_name", "username", "password", "host", "port"}
|
||||
webKeys = []string{"port", "socket", "trusted_proxies"}
|
||||
)
|
||||
|
||||
// getConfigPath 返回按操作系统区分的配置目录和配置文件路径。
|
||||
func getConfigPath() (dir, file string) {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
@@ -50,7 +84,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 +96,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 +124,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 {
|
||||
@@ -96,8 +133,12 @@ func LoadConfig(customPath string) *Config {
|
||||
|
||||
cfg := &Config{
|
||||
Database: DatabaseConfig{
|
||||
Type: "sqlite",
|
||||
DSN: mysqlExampleDSN,
|
||||
Type: "sqlite",
|
||||
DBName: mysqlExampleDBName,
|
||||
Username: mysqlExampleUser,
|
||||
Password: mysqlExamplePassword,
|
||||
Host: mysqlExampleHost,
|
||||
Port: mysqlExamplePort,
|
||||
},
|
||||
Web: WebConfig{
|
||||
Port: defaultPort,
|
||||
@@ -107,41 +148,112 @@ func LoadConfig(customPath string) *Config {
|
||||
Secret: generateSecret(),
|
||||
}
|
||||
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to marshal default config: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configFile, data, 0644); err != nil {
|
||||
log.Fatalf("Failed to write config file %s: %v", configFile, err)
|
||||
}
|
||||
writeConfigFile(configFile, cfg)
|
||||
// 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{}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
log.Printf("Warning: malformed config file %s: %v, using defaults", configFile, err)
|
||||
return applyDefaults(cfg, defaultPath, configFile)
|
||||
}
|
||||
|
||||
return applyDefaults(cfg, defaultPath)
|
||||
cfg = applyDefaults(cfg, defaultPath, configFile)
|
||||
|
||||
// 检查配置文件里缺失的键(如旧版本没有 database 段),缺失项自动补全并回写。
|
||||
if missing := missingConfigKeys(data); len(missing) > 0 {
|
||||
fillDatabaseExamples(&cfg.Database)
|
||||
writeConfigFile(configFile, cfg)
|
||||
log.Printf("Config file %s was missing: %s. Added defaults.", configFile, strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// 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.
|
||||
// writeConfigFile 以 0640 权限写入配置文件;已有文件保留原权限。
|
||||
func writeConfigFile(configFile string, cfg *Config) {
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to marshal config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(configFile, data, 0640); err != nil {
|
||||
log.Fatalf("Failed to write config file %s: %v", configFile, err)
|
||||
}
|
||||
}
|
||||
|
||||
// missingConfigKeys 返回配置文件中缺失的键(含顶层与 database/web 子键)。
|
||||
// 解析失败时不返回任何缺失(调用方已按 malformed 路径处理)。
|
||||
func missingConfigKeys(data []byte) []string {
|
||||
raw := map[string]any{}
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil
|
||||
}
|
||||
var missing []string
|
||||
if db, ok := raw["database"].(map[string]any); ok {
|
||||
for _, k := range databaseKeys {
|
||||
if _, ok := db[k]; !ok {
|
||||
missing = append(missing, "database."+k)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
missing = append(missing, "database")
|
||||
}
|
||||
if web, ok := raw["web"].(map[string]any); ok {
|
||||
for _, k := range webKeys {
|
||||
if _, ok := web[k]; !ok {
|
||||
missing = append(missing, "web."+k)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
missing = append(missing, "web")
|
||||
}
|
||||
if _, ok := raw["path"]; !ok {
|
||||
missing = append(missing, "path")
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// fillDatabaseExamples 用示例值填充数据库段中的空字段,用于补齐缺失配置后的回写。
|
||||
func fillDatabaseExamples(d *DatabaseConfig) {
|
||||
if d.Type == "" {
|
||||
d.Type = "sqlite"
|
||||
}
|
||||
if d.DBName == "" {
|
||||
d.DBName = mysqlExampleDBName
|
||||
}
|
||||
if d.Username == "" {
|
||||
d.Username = mysqlExampleUser
|
||||
}
|
||||
if d.Password == "" {
|
||||
d.Password = mysqlExamplePassword
|
||||
}
|
||||
if d.Host == "" {
|
||||
d.Host = mysqlExampleHost
|
||||
}
|
||||
if d.Port == "" {
|
||||
d.Port = mysqlExamplePort
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +261,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,141 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLDSN(t *testing.T) {
|
||||
d := &DatabaseConfig{
|
||||
DBName: "blog_go",
|
||||
Username: "user",
|
||||
Password: "pass:word",
|
||||
Host: "127.0.0.1",
|
||||
Port: "3306",
|
||||
}
|
||||
want := "user:pass:word@tcp(127.0.0.1:3306)/blog_go?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
if got := d.MySQLDSN(); got != want {
|
||||
t.Fatalf("MySQLDSN() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigFillsMissingKeys 覆盖启动时补齐缺失配置项并回写文件:
|
||||
// 旧格式配置(无 database 段)应被补全,且不覆盖已存在的 path。
|
||||
func TestLoadConfigFillsMissingKeys(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
secret := strings.Repeat("a", 64)
|
||||
old := "secret: " + secret + "\npath: ./data\n"
|
||||
if err := os.WriteFile(path, []byte(old), 0640); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
cfg := LoadConfig(path)
|
||||
|
||||
if cfg.Database.Type != "sqlite" || cfg.Database.DBName != mysqlExampleDBName ||
|
||||
cfg.Database.Username != mysqlExampleUser || cfg.Database.Password != mysqlExamplePassword ||
|
||||
cfg.Database.Host != mysqlExampleHost || cfg.Database.Port != mysqlExamplePort {
|
||||
t.Fatalf("database defaults not filled: %+v", cfg.Database)
|
||||
}
|
||||
if cfg.Web.Port != defaultPort {
|
||||
t.Fatalf("web port = %q, want %q", cfg.Web.Port, defaultPort)
|
||||
}
|
||||
if cfg.Path != "./data" {
|
||||
t.Fatalf("path = %q, want ./data (must not be overwritten)", cfg.Path)
|
||||
}
|
||||
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat config: %v", err)
|
||||
}
|
||||
if perm := st.Mode().Perm(); perm != 0640 {
|
||||
t.Fatalf("config perms = %v, want 0640", perm)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
back := &Config{}
|
||||
if err := yaml.Unmarshal(data, back); err != nil {
|
||||
t.Fatalf("reload config: %v", err)
|
||||
}
|
||||
if back.Database.DBName != mysqlExampleDBName {
|
||||
t.Fatalf("rewritten file db_name = %q, want %q", back.Database.DBName, mysqlExampleDBName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigKeepsCompleteFile 覆盖配置完整时不回写文件。
|
||||
func TestLoadConfigKeepsCompleteFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
content := "database:\n" +
|
||||
" type: mysql\n" +
|
||||
" db_name: myblog\n" +
|
||||
" username: root\n" +
|
||||
" password: p@ss\n" +
|
||||
" host: db.local\n" +
|
||||
" port: \"3307\"\n" +
|
||||
"web:\n" +
|
||||
" port: \"8080\"\n" +
|
||||
" socket: \"\"\n" +
|
||||
" trusted_proxies:\n" +
|
||||
" - 127.0.0.1\n" +
|
||||
" - ::1\n" +
|
||||
"path: ./data\n" +
|
||||
"secret: " + strings.Repeat("a", 64) + "\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0640); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
cfg := LoadConfig(path)
|
||||
|
||||
if cfg.Database.Password != "p@ss" {
|
||||
t.Fatalf("password = %q, want p@ss (must not be overwritten)", cfg.Database.Password)
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
if !bytes.Equal(after, []byte(content)) {
|
||||
t.Fatalf("complete config file was rewritten:\n%s", after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigMalformedNoRewrite 覆盖解析失败的配置文件不被回写。
|
||||
func TestLoadConfigMalformedNoRewrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
// web.port 为序列不能解码到 string,yaml.Unmarshal 报错。
|
||||
content := "secret: " + strings.Repeat("a", 64) + "\nweb:\n port: [8080]\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0640); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
LoadConfig(path)
|
||||
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
if !bytes.Equal(after, []byte(content)) {
|
||||
t.Fatalf("malformed config was rewritten:\n%s", after)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+147
-124
@@ -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,20 +119,21 @@ 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 使用给定的表单值和可选的错误消息渲染管理员工作区的
|
||||
// 文章表单(与作者工作区共用一份模板,FormIsMy=false 表示管理员变体)。
|
||||
func renderArticleForm(c *gin.Context, db *gorm.DB, f articleForm, errMsg string) {
|
||||
data := DefaultData(c)
|
||||
data["Title"] = f.TitleText
|
||||
data["FormIsMy"] = false
|
||||
if errMsg != "" {
|
||||
data["Error"] = errMsg
|
||||
}
|
||||
applyFormToData(data, f)
|
||||
c.HTML(http.StatusOK, "article_create", data)
|
||||
c.HTML(http.StatusOK, "article_form", 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 +154,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 +163,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 +177,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 +186,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 +202,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 +226,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 +241,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 +253,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 +266,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 +305,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 +327,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 +374,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 +386,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 +406,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 +451,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 +473,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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// TestArticleDetailEditButton 验证文章页编辑按钮的可见性:
|
||||
// - 文章作者(普通用户)可见,链接指向 /my/articles/:id/edit
|
||||
// - 管理员对所有文章可见,链接指向 /admin/articles/:id/edit
|
||||
// - 其他登录用户与未登录访客不可见
|
||||
func TestArticleDetailEditButton(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
|
||||
var aliceArt models.Article
|
||||
if err := e.db.Where("slug = ?", "alice-post").First(&aliceArt).Error; err != nil {
|
||||
t.Fatalf("alice article not found: %v", err)
|
||||
}
|
||||
id := strconv.FormatUint(uint64(aliceArt.ID), 10)
|
||||
myEdit := "/my/articles/" + id + "/edit"
|
||||
adminEdit := "/admin/articles/" + id + "/edit"
|
||||
|
||||
// 未登录访客:两种链接都不得出现。
|
||||
w := e.do(http.MethodGet, "/article/alice-post", "", nil, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("anonymous GET article: status = %d", w.Code)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), myEdit) || strings.Contains(w.Body.String(), adminEdit) {
|
||||
t.Fatal("anonymous viewer must not see any edit button")
|
||||
}
|
||||
|
||||
// 文章作者:看到 /my/articles/:id/edit。
|
||||
alice := e.login(t, "alice")
|
||||
w = e.do(http.MethodGet, "/article/alice-post", alice, nil, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("author GET article: status = %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), myEdit) {
|
||||
t.Fatal("author should see their own edit button")
|
||||
}
|
||||
if strings.Contains(w.Body.String(), adminEdit) {
|
||||
t.Fatal("author must not see the admin edit button")
|
||||
}
|
||||
|
||||
// 其他作者:不可见。
|
||||
bob := e.login(t, "bob")
|
||||
w = e.do(http.MethodGet, "/article/alice-post", bob, nil, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("other author GET article: status = %d", w.Code)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), myEdit) || strings.Contains(w.Body.String(), adminEdit) {
|
||||
t.Fatal("other author must not see the edit button")
|
||||
}
|
||||
|
||||
// 管理员:看到 /admin/articles/:id/edit。
|
||||
admin := e.login(t, "admin")
|
||||
w = e.do(http.MethodGet, "/article/alice-post", admin, nil, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("admin GET article: status = %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), adminEdit) {
|
||||
t.Fatal("admin should see the admin edit button")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestArticleFormTemplateVariants 验证管理员与作者工作区共用同一份
|
||||
// article_form 模板(templates/partials/article_form.html)时的两个变体:
|
||||
// - 管理员:含置顶勾选(is_top)/ /api/admin/articles 前缀,作者 API 不出现
|
||||
// - 作者:含状态下拉 / /api/my/articles 前缀,置顶与管理员 API 不出现
|
||||
func TestArticleFormTemplateVariants(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
|
||||
// 管理员新建页(FormIsMy=false 变体)。
|
||||
admin := e.login(t, "admin")
|
||||
w := e.do(http.MethodGet, "/admin/articles/new", admin, nil, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("admin GET /admin/articles/new: status = %d", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{`id="articleForm"`, `id="articleSessionToken"`, "isTopCheckbox", `"/api/admin/articles"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("admin variant missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "/api/my/articles") {
|
||||
t.Fatal("admin variant must not reference the author API")
|
||||
}
|
||||
|
||||
// 作者新建页(FormIsMy=true 变体)。
|
||||
alice := e.login(t, "alice")
|
||||
w = e.do(http.MethodGet, "/my/articles/new", alice, nil, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("author GET /my/articles/new: status = %d", w.Code)
|
||||
}
|
||||
body = w.Body.String()
|
||||
for _, want := range []string{`id="articleForm"`, `id="articleSessionToken"`, `"/api/my/articles"`, `name="status"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("author variant missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbid := range []string{"isTopCheckbox", "/api/admin/articles"} {
|
||||
if strings.Contains(body, forbid) {
|
||||
t.Fatalf("author variant must not contain %q", forbid)
|
||||
}
|
||||
}
|
||||
}
|
||||
+144
-65
@@ -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,38 @@ 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 内容寻址,实现磁盘去重。
|
||||
//
|
||||
// 记录写入全站统一的 files 表(Type=attachments),与历史数据同属一个表。
|
||||
func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uploaderID, ok := sessionAuthorID(c)
|
||||
@@ -63,12 +88,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 +107,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,31 +122,26 @@ 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"})
|
||||
return
|
||||
}
|
||||
dstPath := filepath.Join(dir, stored)
|
||||
if _, err := os.Stat(dstPath); os.IsNotExist(err) {
|
||||
if err := os.WriteFile(dstPath, content, 0644); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
att := models.Attachment{
|
||||
att := models.File{
|
||||
Type: models.FileTypeAttachment,
|
||||
ArticleID: articleID,
|
||||
SessionToken: token,
|
||||
UploaderID: uploaderID,
|
||||
@@ -126,8 +152,9 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
Size: header.Size,
|
||||
Category: check.Type.Category,
|
||||
}
|
||||
if err := db.Create(&att).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record attachment"})
|
||||
// 统一上传引擎:磁盘去重写入(SHA-256 内容寻址)+ files 表登记。
|
||||
if _, err := saveUploadedFile(db, att, attachmentsDir(storagePath), content); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save attachment"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -142,19 +169,55 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Delete ----------------
|
||||
// saveUploadedFile 是全站统一上传引擎(files 表 + 内容寻址磁盘存储):
|
||||
// - 确保 dir 存在;
|
||||
// - 以 f.StoredName(内容 SHA-256 十六进制)为磁盘文件名,文件已存在则
|
||||
// 跳过写入(磁盘去重,内容寻址文件可被多行记录共享);
|
||||
// - 在 files 表登记一条记录(Type/UploaderID/ArticleID 等由调用方给定)。
|
||||
//
|
||||
// 附件、头像等所有上传类型共用本引擎。
|
||||
func saveUploadedFile(db *gorm.DB, f models.File, dir string, content []byte) (models.File, error) {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return f, err
|
||||
}
|
||||
dstPath := filepath.Join(dir, f.StoredName)
|
||||
if _, err := os.Stat(dstPath); os.IsNotExist(err) {
|
||||
if err := os.WriteFile(dstPath, content, 0644); err != nil {
|
||||
return f, err
|
||||
}
|
||||
}
|
||||
if err := db.Create(&f).Error; err != nil {
|
||||
return f, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// 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 软删除附件记录(files 表,Type=attachments),仅当
|
||||
// 没有其余记录引用时才删除磁盘文件(引用计数,因为内容寻址的文件可能
|
||||
// 被共享)。只有管理员、上传者或文件所在文章的作者可以删除。
|
||||
func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := parseUintParam(c, "id")
|
||||
var att models.Attachment
|
||||
if err := db.First(&att, id).Error; err != nil {
|
||||
var att models.File
|
||||
if err := db.First(&att, "id = ? AND type = ?", id, models.FileTypeAttachment).Error; err != nil {
|
||||
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,25 +225,35 @@ 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)
|
||||
db.Model(&models.File{}).Where("stored_name = ? AND type = ?", stored, models.FileTypeAttachment).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 返回文章的附件(供编辑页加载时重新填充列表)。
|
||||
// 只读取 files 表中 Type=attachments 的记录。
|
||||
// 只有文章作者(或管理员)可以列出。
|
||||
func ListAttachments(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
articleID := parseUintParam(c, "id")
|
||||
var atts []models.Attachment
|
||||
db.Where("article_id = ?", articleID).Order("created_at ASC").Find(&atts)
|
||||
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.File
|
||||
db.Where("article_id = ? AND type = ?", articleID, models.FileTypeAttachment).
|
||||
Order("created_at ASC").Find(&atts)
|
||||
|
||||
out := make([]gin.H, 0, len(atts))
|
||||
for _, a := range atts {
|
||||
@@ -197,36 +270,42 @@ 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
|
||||
}
|
||||
return db.Model(&models.Attachment{}).
|
||||
Where("session_token = ? AND article_id = 0", token).
|
||||
return db.Model(&models.File{}).
|
||||
Where("session_token = ? AND article_id = 0 AND type = ?", token, models.FileTypeAttachment).
|
||||
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,229 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// TestAvatarUploadGifStoredRaw 验证 GIF 头像跳过裁剪/缩放/重编码:
|
||||
// 原始字节原样存储(保留动画),stored_name 为其 SHA-256,
|
||||
// files 行为 ext=.gif / mime=image/gif,磁盘内容与上传字节完全一致。
|
||||
func TestAvatarUploadGifStoredRaw(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
_ = e.db.Create(&models.UploadFileType{Extension: ".gif", MimeType: "image/gif", Category: models.CategoryImage, Enabled: true})
|
||||
models.LoadConfigCache(e.db)
|
||||
|
||||
alice := e.login(t, "alice")
|
||||
token := e.csrfTokenFor(t, alice)
|
||||
aliceID := userIDByUsername(t, e.db, "alice")
|
||||
|
||||
// 1x1 GIF89a(43 字节)。
|
||||
gifBytes := []byte{
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x21, 0xF9, 0x04, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02,
|
||||
0x44, 0x01, 0x00, 0x3B,
|
||||
}
|
||||
|
||||
w := avatarPOST(e, alice, token, gifBytes, "avatar.gif")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("gif avatar upload: status = %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(resp.Avatar) != 64 || !isHexString(resp.Avatar) {
|
||||
t.Fatalf("avatar stored name = %q, want 64-char sha256 hex", resp.Avatar)
|
||||
}
|
||||
|
||||
// 原始字节原样存储:磁盘内容 == 上传的 GIF 字节,stored_name 为其哈希。
|
||||
raw, err := os.ReadFile(filepath.Join(e.storageDir, "avatars", resp.Avatar))
|
||||
if err != nil {
|
||||
t.Fatalf("gif avatar disk file missing: %v", err)
|
||||
}
|
||||
if !bytes.Equal(raw, gifBytes) {
|
||||
t.Fatalf("gif bytes must be stored raw (len %d -> %d)", len(gifBytes), len(raw))
|
||||
}
|
||||
if sum := sha256.Sum256(gifBytes); hex.EncodeToString(sum[:]) != resp.Avatar {
|
||||
t.Fatal("stored_name must be sha256 of raw gif bytes")
|
||||
}
|
||||
|
||||
// files 行:type=avatars、ext=.gif、mime=image/gif、大小=原始字节数。
|
||||
var f models.File
|
||||
if err := e.db.Where("type = ? AND stored_name = ?", models.FileTypeAvatar, resp.Avatar).First(&f).Error; err != nil {
|
||||
t.Fatalf("files row not found: %v", err)
|
||||
}
|
||||
if f.Ext != ".gif" || f.MIME != "image/gif" {
|
||||
t.Fatalf("avatar row ext/mime = %q/%q, want .gif/image/gif", f.Ext, f.MIME)
|
||||
}
|
||||
if f.Size != int64(len(gifBytes)) {
|
||||
t.Fatalf("avatar size = %d, want %d", f.Size, len(gifBytes))
|
||||
}
|
||||
if f.UploaderID != aliceID {
|
||||
t.Fatalf("uploader_id = %d, want %d", f.UploaderID, aliceID)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestPNG 生成一张指定大小的纯色 PNG。
|
||||
func newTestPNG(t *testing.T, w, h int, c color.RGBA) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// isHexString 报告字符串是否全部为十六进制字符。
|
||||
func isHexString(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if !strings.ContainsRune("0123456789abcdef", r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// avatarPOST 以 multipart 提交头像上传(表单字段名 avatar)。
|
||||
func avatarPOST(e *securityTestEnv, cookie, csrf string, img []byte, filename string) *httptest.ResponseRecorder {
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
fw, _ := mw.CreateFormFile("avatar", filename)
|
||||
_, _ = fw.Write(img)
|
||||
_ = mw.Close()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/profile/avatar", &buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("X-CSRF-Token", csrf)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
w := httptest.NewRecorder()
|
||||
e.router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// TestAvatarUploadRegistersFileRow 验证头像上传走统一上传引擎:
|
||||
// files 表登记 type=avatars、stored_name=处理内容 SHA-256 哈希、
|
||||
// 用户 Avatar 字段与磁盘文件 avatars/<哈希>、公开 URL /uploads/avatars/<哈希>。
|
||||
// 更换头像时:旧登记行软删除;旧磁盘文件无其他引用即清理。
|
||||
func TestAvatarUploadRegistersFileRow(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
|
||||
// 允许 .png(平台策略允许的图片类型)。
|
||||
_ = e.db.Create(&models.UploadFileType{Extension: ".png", MimeType: "image/png", Category: models.CategoryImage, Enabled: true})
|
||||
models.LoadConfigCache(e.db)
|
||||
|
||||
alice := e.login(t, "alice")
|
||||
token := e.csrfTokenFor(t, alice)
|
||||
aliceID := userIDByUsername(t, e.db, "alice")
|
||||
|
||||
// --- 第一次上传 ---
|
||||
img1 := newTestPNG(t, 16, 16, color.RGBA{R: 200, G: 30, B: 30, A: 255})
|
||||
w := avatarPOST(e, alice, token, img1, "avatar.png")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("avatar upload: status = %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(resp.Avatar) != 64 || !isHexString(resp.Avatar) {
|
||||
t.Fatalf("avatar stored name = %q, want 64-char sha256 hex", resp.Avatar)
|
||||
}
|
||||
name1 := resp.Avatar
|
||||
|
||||
// files 表记录:type=avatars + 哈希存储名 + 归属上传者。
|
||||
var f models.File
|
||||
if err := e.db.Where("type = ? AND stored_name = ?", models.FileTypeAvatar, name1).First(&f).Error; err != nil {
|
||||
t.Fatalf("files row (type=avatars) not found: %v", err)
|
||||
}
|
||||
if f.UploaderID != aliceID {
|
||||
t.Fatalf("uploader_id = %d, want %d", f.UploaderID, aliceID)
|
||||
}
|
||||
if f.Category != models.CategoryImage || f.Ext != ".jpg" || f.MIME != "image/jpeg" {
|
||||
t.Fatalf("avatar row category/ext/mime = %q/%q/%q", f.Category, f.Ext, f.MIME)
|
||||
}
|
||||
|
||||
// 用户记录头像 = 哈希。
|
||||
var u models.User
|
||||
if err := e.db.First(&u, aliceID).Error; err != nil {
|
||||
t.Fatalf("load user: %v", err)
|
||||
}
|
||||
if u.Avatar != name1 {
|
||||
t.Fatalf("user.Avatar = %q, want %q", u.Avatar, name1)
|
||||
}
|
||||
|
||||
// 磁盘文件位于 avatars/<哈希>,内容为处理后的 256x256 JPEG,且与哈希一致。
|
||||
diskPath := filepath.Join(e.storageDir, "avatars", name1)
|
||||
raw, err := os.ReadFile(diskPath)
|
||||
if err != nil {
|
||||
t.Fatalf("avatar disk file missing: %v", err)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
t.Fatal("avatar disk file is empty")
|
||||
}
|
||||
if sum := sha256.Sum256(raw); hex.EncodeToString(sum[:]) != name1 {
|
||||
t.Fatal("disk file content does not match stored_name (sha256)")
|
||||
}
|
||||
|
||||
// --- 更换头像 ---
|
||||
img2 := newTestPNG(t, 16, 16, color.RGBA{R: 30, G: 30, B: 200, A: 255})
|
||||
w = avatarPOST(e, alice, token, img2, "avatar2.png")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("second avatar upload: status = %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode second response: %v", err)
|
||||
}
|
||||
if resp.Avatar == "" || resp.Avatar == name1 {
|
||||
t.Fatalf("second avatar name = %q, want a new hash", resp.Avatar)
|
||||
}
|
||||
name2 := resp.Avatar
|
||||
|
||||
// 旧的 avatars 登记行已软删除(活动查询不可见,Unscoped 可见且 DeletedAt 非空)。
|
||||
var oldRow models.File
|
||||
if err := e.db.Unscoped().Where("type = ? AND stored_name = ?", models.FileTypeAvatar, name1).First(&oldRow).Error; err != nil {
|
||||
t.Fatalf("old avatar row not found (unscoped): %v", err)
|
||||
}
|
||||
if !oldRow.DeletedAt.Valid {
|
||||
t.Fatal("old avatar row should be soft-deleted")
|
||||
}
|
||||
var active models.File
|
||||
if err := e.db.Where("type = ? AND stored_name = ?", models.FileTypeAvatar, name2).First(&active).Error; err != nil {
|
||||
t.Fatalf("new avatar row not found: %v", err)
|
||||
}
|
||||
|
||||
// 旧磁盘文件无其他引用,应被清理。
|
||||
if _, err := os.Stat(filepath.Join(e.storageDir, "avatars", name1)); !os.IsNotExist(err) {
|
||||
t.Fatal("old avatar disk file should have been removed")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(e.storageDir, "avatars", name2)); err != nil {
|
||||
t.Fatalf("new avatar disk file missing: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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.File{}).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()
|
||||
}
|
||||
+78
-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)
|
||||
|
||||
@@ -239,11 +248,28 @@ func renderArticleDetail(c *gin.Context, db *gorm.DB, article *models.Article, f
|
||||
data["CommentError"] = formErr
|
||||
data["CommentNotice"] = notice
|
||||
data["MaxCommentLength"] = MaxCommentLength
|
||||
|
||||
// 文章页编辑按钮:管理员可编辑全部文章;登录用户仅可编辑自己的文章
|
||||
// (普通作者跳转 /my/articles/:id/edit,编辑页/接口均有 author_id 所有权约束)。
|
||||
canEdit := false
|
||||
editURL := ""
|
||||
uid := userIDFromSession(c)
|
||||
role, _ := c.Get("role")
|
||||
if r, _ := role.(string); r == models.RoleAdmin {
|
||||
canEdit = true
|
||||
editURL = fmt.Sprintf("/admin/articles/%d/edit", article.ID)
|
||||
} else if uid != 0 && uid == article.AuthorID {
|
||||
canEdit = true
|
||||
editURL = fmt.Sprintf("/my/articles/%d/edit", article.ID)
|
||||
}
|
||||
data["CanEdit"] = canEdit
|
||||
data["EditURL"] = editURL
|
||||
|
||||
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 +277,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 +305,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 +323,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 +364,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)
|
||||
}
|
||||
}
|
||||
+46
-32
@@ -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,44 +138,54 @@ 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 为普通用户渲染文章表单
|
||||
// (与管理员工作区共用一份模板,FormIsMy=true 表示作者变体)。
|
||||
func renderMyArticleForm(c *gin.Context, db *gorm.DB, f articleForm, errMsg string) {
|
||||
data := DefaultData(c)
|
||||
data["Title"] = f.TitleText
|
||||
data["FormIsMy"] = true
|
||||
if errMsg != "" {
|
||||
data["Error"] = errMsg
|
||||
}
|
||||
applyFormToData(data, f)
|
||||
c.HTML(http.StatusOK, "my_article_form", data)
|
||||
c.HTML(http.StatusOK, "article_form", data)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+132
-106
@@ -2,6 +2,8 @@ package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
@@ -19,10 +21,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 +46,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 +60,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 +89,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 +173,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,55 +188,100 @@ 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.
|
||||
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)
|
||||
// GIF 直接存储原始字节(保留动画,不做裁剪/缩放/重编码);
|
||||
// 其余图片类型解码、缩放 256x256 并重编码为 JPEG。
|
||||
var storedBytes []byte
|
||||
var finalExt, finalMIME string
|
||||
if check.Type.MimeType == "image/gif" {
|
||||
storedBytes = imgBytes
|
||||
finalExt = ".gif"
|
||||
finalMIME = "image/gif"
|
||||
} else {
|
||||
processedBytes, extOut, err := processAvatar(imgBytes, ext)
|
||||
if err != nil {
|
||||
// SECURITY (#21):直接拒绝无法解码的载荷——存储原始字节
|
||||
// 会让非图像内容进入 avatars/ 目录。
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid image file"})
|
||||
return
|
||||
}
|
||||
storedBytes = processedBytes
|
||||
finalExt = extOut
|
||||
finalMIME = "image/jpeg"
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 统一上传引擎:SHA-256 内容寻址写入 avatars/ 目录,并在 files 表
|
||||
// 登记一条记录(Type=avatars)。公开链接 /uploads/avatars/<哈希>。
|
||||
sum := sha256.Sum256(storedBytes)
|
||||
storedName := hex.EncodeToString(sum[:])
|
||||
|
||||
oldAvatar := user.Avatar // 替换前的旧头像(旧命名 <uid>.jpg 或哈希)
|
||||
|
||||
f := models.File{
|
||||
Type: models.FileTypeAvatar,
|
||||
UploaderID: user.ID,
|
||||
Filename: header.Filename,
|
||||
StoredName: storedName,
|
||||
Ext: finalExt,
|
||||
MIME: finalMIME,
|
||||
Size: int64(len(storedBytes)),
|
||||
Category: models.CategoryImage,
|
||||
}
|
||||
if _, err := saveUploadedFile(db, f, filepath.Join(storagePath, "avatars"), storedBytes); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save avatar"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update user record.
|
||||
user.Avatar = savedName
|
||||
// 更新用户记录与会话(Avatar 存哈希,公开 URL /uploads/avatars/<哈希>)。
|
||||
user.Avatar = storedName
|
||||
db.Save(&user)
|
||||
|
||||
// Update session.
|
||||
session.Set("avatar", savedName)
|
||||
session.Set("avatar", storedName)
|
||||
session.Save()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"avatar": savedName})
|
||||
// 清理旧的头像记录与文件:
|
||||
// - 软删除旧的 avatars 登记行(保留历史查询痕迹,仅从活动查询隐藏);
|
||||
// - 仅当无其他 files 记录或其他用户引用时删除磁盘文件,
|
||||
// 避免误删被共享的内容寻址文件(引用计数语义与附件一致)。
|
||||
if oldAvatar != "" && oldAvatar != storedName {
|
||||
db.Where("type = ? AND stored_name = ?", models.FileTypeAvatar, oldAvatar).
|
||||
Delete(&models.File{})
|
||||
var refs int64
|
||||
db.Model(&models.File{}).Where("type = ? AND stored_name = ?", models.FileTypeAvatar, oldAvatar).Count(&refs)
|
||||
var otherUsers int64
|
||||
db.Model(&models.User{}).Where("avatar = ? AND id <> ?", oldAvatar, user.ID).Count(&otherUsers)
|
||||
if refs == 0 && otherUsers == 0 {
|
||||
os.Remove(filepath.Join(storagePath, "avatars", oldAvatar)) // 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"avatar": storedName})
|
||||
}
|
||||
}
|
||||
|
||||
// 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,583 @@
|
||||
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.File{}, &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))
|
||||
r.GET("/article/:slug", ArticleDetail(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)
|
||||
})
|
||||
protected.GET("/articles/new", MyArticleCreatePage(db))
|
||||
}
|
||||
|
||||
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))
|
||||
admin.GET("/articles/new", ArticleCreatePage(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.File
|
||||
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.File{}).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,280 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"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 len(alice.Avatar) != 64 || !isHexString(alice.Avatar) {
|
||||
t.Fatalf("avatar = %q, want 64-char sha256 hex (hash-based name)", alice.Avatar)
|
||||
}
|
||||
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 len(alice.Avatar) != 64 || !isHexString(alice.Avatar) {
|
||||
t.Fatalf("avatar = %q, want 64-char sha256 hex (hash-based name)", alice.Avatar)
|
||||
}
|
||||
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)
|
||||
}
|
||||
+84
-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",
|
||||
@@ -178,8 +185,10 @@ var translations = map[Lang]map[string]string{
|
||||
"article_att_uploading": "Uploading...",
|
||||
"article_att_error": "Upload failed. Please try again.",
|
||||
"article_att_delete_confirm": "Delete this attachment?",
|
||||
"article_image_upload": "Upload image",
|
||||
"article_image_not_image": "The file is not a valid image.",
|
||||
|
||||
// Article management
|
||||
// 文章管理
|
||||
"article_list_title": "Articles",
|
||||
"my_articles_title": "My Articles",
|
||||
"article_edit_title": "Edit Article",
|
||||
@@ -196,21 +205,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 +263,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 +293,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 +333,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 +356,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 +365,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 +401,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 +446,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 +490,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 +519,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 +566,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",
|
||||
@@ -596,6 +628,8 @@ var translations = map[Lang]map[string]string{
|
||||
"article_att_uploading": "上传中...",
|
||||
"article_att_error": "上传失败,请重试。",
|
||||
"article_att_delete_confirm": "删除该附件?",
|
||||
"article_image_upload": "上传图片",
|
||||
"article_image_not_image": "该文件不是有效的图片。",
|
||||
|
||||
// 文章管理
|
||||
"article_list_title": "文章管理",
|
||||
@@ -672,6 +706,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 +844,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 +889,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 +919,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]
|
||||
|
||||
+13
-3
@@ -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
|
||||
@@ -53,7 +53,11 @@ if [[ ! -f "${CONFIG_DIR}/config.yaml" ]]; then
|
||||
cat > "${CONFIG_DIR}/config.yaml" <<EOF
|
||||
database:
|
||||
type: sqlite
|
||||
dsn: ""
|
||||
db_name: ""
|
||||
username: ""
|
||||
password: ""
|
||||
host: ""
|
||||
port: ""
|
||||
web:
|
||||
port: "8080"
|
||||
socket: ${SOCKET_PATH}
|
||||
@@ -77,7 +81,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
|
||||
@@ -97,3 +101,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(本机其他用户也无法再直连)。"
|
||||
@@ -9,182 +9,120 @@ import (
|
||||
"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 embeds the static assets (Markdown CSS/JS) into the binary so a
|
||||
// deployment only needs to replace the executable — no separate static
|
||||
// directory has to be copied to the server.
|
||||
// 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 (embedded into the binary).
|
||||
// 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"
|
||||
@@ -206,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)
|
||||
@@ -218,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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Attachment represents a file attached to an article.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
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
|
||||
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
|
||||
Ext string `gorm:"size:32" json:"ext"`
|
||||
MIME string `gorm:"size:128" json:"mime"`
|
||||
Size int64 `gorm:"default:0" json:"size"`
|
||||
Category string `gorm:"size:32" json:"category"` // image/document/archive/video/other
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
func (Attachment) TableName() string {
|
||||
return "attachments"
|
||||
}
|
||||
|
||||
// AttachmentCategoryImage reports whether this attachment is an image (used to
|
||||
// decide markdown insertion form: ![]() vs []()).
|
||||
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()
|
||||
|
||||
+114
-14
@@ -1,6 +1,8 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -14,12 +16,50 @@ 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)
|
||||
}
|
||||
|
||||
// validateMySQLConfig 校验 MySQL 连接所需字段均非空。
|
||||
func validateMySQLConfig(d *config.DatabaseConfig) error {
|
||||
if d.DBName == "" {
|
||||
return errors.New("'db_name' is required when type is 'mysql'")
|
||||
}
|
||||
if d.Username == "" {
|
||||
return errors.New("'username' is required when type is 'mysql'")
|
||||
}
|
||||
if d.Password == "" {
|
||||
return errors.New("'password' is required when type is 'mysql'")
|
||||
}
|
||||
if d.Host == "" {
|
||||
return errors.New("'host' is required when type is 'mysql'")
|
||||
}
|
||||
if d.Port == "" {
|
||||
return errors.New("'port' is required when type is 'mysql'")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -28,10 +68,10 @@ func InitDB(cfg *config.Config) *gorm.DB {
|
||||
|
||||
switch cfg.Database.Type {
|
||||
case "mysql":
|
||||
if cfg.Database.DSN == "" {
|
||||
log.Fatalf("Database DSN is required when type is 'mysql'. Please set it in your config file.")
|
||||
if err := validateMySQLConfig(&cfg.Database); err != nil {
|
||||
log.Fatalf("Invalid MySQL config: %v", err)
|
||||
}
|
||||
dialector = mysql.Open(cfg.Database.DSN)
|
||||
dialector = mysql.Open(cfg.Database.MySQLDSN())
|
||||
default:
|
||||
dbPath := filepath.Join(cfg.Path, "blog.db")
|
||||
dialector = sqlite.Open(dbPath)
|
||||
@@ -44,18 +84,23 @@ 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 {
|
||||
// 自动迁移数据表(幂等操作)。attachments 旧表已由 files 替代,
|
||||
// 不再参与迁移;历史数据在下方 migrateAttachmentsToFiles 中一次性搬运。
|
||||
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}, &File{}, &Comment{}, &CommentConfig{}, &ArticleView{}, &NavLink{}, &Tag{}, &ArticleTag{}); err != nil {
|
||||
log.Fatalf("Failed to auto-migrate database: %v", err)
|
||||
}
|
||||
|
||||
// Seed site platform configuration on first run.
|
||||
// 数据迁移:attachments → 全站统一 files 表(type='attachments'),幂等。
|
||||
// 仅当旧表仍存在时执行(已删除则为无操作),保证升级路径上的数据不丢。
|
||||
migrateAttachmentsToFiles(db)
|
||||
|
||||
// 首次运行时初始化站点平台配置。
|
||||
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 +111,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)
|
||||
@@ -89,3 +137,55 @@ func InitDB(cfg *config.Config) *gorm.DB {
|
||||
DB = db
|
||||
return db
|
||||
}
|
||||
|
||||
// migrateAttachmentsToFiles 将旧 attachments 表中的历史数据复制到统一的
|
||||
// files 表,type 一律标记为 "attachments"。仅当 attachments 表仍存在时
|
||||
// 执行——新安装从未创建过该表,而已经切换的部署会将其删除。
|
||||
//
|
||||
// 幂等与异常恢复策略:
|
||||
// 1. 按主键 id 对齐——files 中已存在同 id 的行视为已迁移并跳过;
|
||||
// 2. 若 attachments 表被重建(例如中途部署过旧版二进制,AutoMigrate 把表
|
||||
// 从 id=1 重新编号),旧 id 已被其他内容占用,改用 stored_name(内容
|
||||
// SHA-256)比对补齐,避免新上传被静默漏搬。
|
||||
// 含软删除行一并复制;重复执行不会产生重复数据。
|
||||
func migrateAttachmentsToFiles(db *gorm.DB) {
|
||||
if !db.Migrator().HasTable("attachments") {
|
||||
return
|
||||
}
|
||||
|
||||
// 1) id 对齐迁移(常规升级路径)。
|
||||
res := db.Exec(`
|
||||
INSERT INTO files
|
||||
(id, type, article_id, session_token, uploader_id, filename, stored_name, ext, mime, size, category, created_at, updated_at, deleted_at)
|
||||
SELECT
|
||||
a.id, 'attachments', a.article_id, a.session_token, a.uploader_id, a.filename,
|
||||
a.stored_name, a.ext, a.mime, a.size, a.category, a.created_at, a.updated_at, a.deleted_at
|
||||
FROM attachments a
|
||||
WHERE NOT EXISTS (SELECT 1 FROM files f WHERE f.id = a.id)`)
|
||||
if res.Error != nil {
|
||||
log.Printf("Migration attachments -> files failed: %v", res.Error)
|
||||
return
|
||||
}
|
||||
if res.RowsAffected > 0 {
|
||||
log.Printf("Migration: copied %d attachment(s) into files table (type=attachments)", res.RowsAffected)
|
||||
}
|
||||
|
||||
// 2) 内容补齐:id 已存在但指向不同内容(重建表后 id 撞号)的行,
|
||||
// 省略 id 让数据库重新分配,且跳过内容已在 files 中登记的行(去重)。
|
||||
res = db.Exec(`
|
||||
INSERT INTO files
|
||||
(type, article_id, session_token, uploader_id, filename, stored_name, ext, mime, size, category, created_at, updated_at, deleted_at)
|
||||
SELECT
|
||||
'attachments', a.article_id, a.session_token, a.uploader_id, a.filename,
|
||||
a.stored_name, a.ext, a.mime, a.size, a.category, a.created_at, a.updated_at, a.deleted_at
|
||||
FROM attachments a
|
||||
WHERE EXISTS (SELECT 1 FROM files f WHERE f.id = a.id AND f.stored_name <> a.stored_name)
|
||||
AND NOT EXISTS (SELECT 1 FROM files f WHERE f.stored_name = a.stored_name)`)
|
||||
if res.Error != nil {
|
||||
log.Printf("Migration attachments -> files (content fallback) failed: %v", res.Error)
|
||||
return
|
||||
}
|
||||
if res.RowsAffected > 0 {
|
||||
log.Printf("Migration: recovered %d colliding attachment(s) into files table by content", res.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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// File 是全站统一的上传文件登记表,用于管理所有类型的上传文件
|
||||
// (附件、头像、Logo 等)。字段与原 attachments 表对齐,额外通过
|
||||
// Type 字段区分文件归属类型。
|
||||
//
|
||||
// 生命周期(方案 A——先上传后绑定):
|
||||
// - 在文章创建页面上文章尚不存在,因此 ArticleID 为 0,
|
||||
// 该行暂时由 SessionToken(为页面会话生成的随机值)持有。
|
||||
// - 保存文章时,ArticleCreate 通过 SessionToken 绑定待处理行,
|
||||
// 设置它们的 ArticleID 并清除令牌。
|
||||
// - 在编辑页面上,上传直接携带真实的 ArticleID。
|
||||
//
|
||||
// 磁盘去重:StoredName 是文件内容的 SHA-256。写入前,
|
||||
// 处理器会检查磁盘上是否已存在同名文件;若已存在则复用(不重写)。
|
||||
// 删除采用引用计数——仅当没有任何 File 行引用时,才删除磁盘文件。
|
||||
type File struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Type string `gorm:"size:32;index;default:attachments" json:"type"` // 归属类型:attachment 等
|
||||
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"` // 原始文件名
|
||||
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"`
|
||||
Category string `gorm:"size:32" json:"category"` // image/document/archive/video/other
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
|
||||
}
|
||||
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (File) TableName() string {
|
||||
return "files"
|
||||
}
|
||||
|
||||
// FileTypeAttachment 是文件归属类型常量:文章附件(原 attachments 表历史数据)。
|
||||
const FileTypeAttachment = "attachments"
|
||||
|
||||
// FileTypeAvatar 是文件归属类型常量:用户头像(存储于 avatars/ 目录,
|
||||
// 公开下载链接为 /uploads/avatars/<stored_name>)。
|
||||
const FileTypeAvatar = "avatars"
|
||||
|
||||
// IsImage 报告该文件是否为图片(用于决定 Markdown 插入形式:![]() 还是 []())。
|
||||
func (f *File) IsImage() bool {
|
||||
return f.Category == CategoryImage
|
||||
}
|
||||
+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
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Migration: 新增全站统一上传文件表 files,并把 attachments 数据迁入(type='attachments')
|
||||
-- Date: 2026-08-28
|
||||
-- Description: files 表用于管理全站所有上传文件(附件/头像/Logo 等),
|
||||
-- Type 字段区分归属类型;attachments 历史数据逐行复制,type 填 'attachments'。
|
||||
-- 幂等:表用 IF NOT EXISTS,数据按主键 id 对齐跳过已迁移行,可重复执行。
|
||||
|
||||
-- 1) 建表(与 GORM AutoMigrate 输出一致)
|
||||
CREATE TABLE IF NOT EXISTS `files` (
|
||||
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`type` varchar(32) DEFAULT 'attachments',
|
||||
`article_id` bigint(20) unsigned DEFAULT NULL,
|
||||
`session_token` varchar(64) DEFAULT NULL,
|
||||
`uploader_id` bigint(20) unsigned DEFAULT NULL,
|
||||
`filename` varchar(255) DEFAULT NULL,
|
||||
`stored_name` varchar(64) DEFAULT NULL,
|
||||
`ext` varchar(32) DEFAULT NULL,
|
||||
`mime` varchar(128) DEFAULT NULL,
|
||||
`size` bigint(20) DEFAULT 0,
|
||||
`category` varchar(32) DEFAULT NULL,
|
||||
`created_at` datetime(3) DEFAULT NULL,
|
||||
`updated_at` datetime(3) DEFAULT NULL,
|
||||
`deleted_at` datetime(3) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_files_type` (`type`),
|
||||
KEY `idx_files_article_id` (`article_id`),
|
||||
KEY `idx_files_session_token` (`session_token`),
|
||||
KEY `idx_files_uploader_id` (`uploader_id`),
|
||||
KEY `idx_files_stored_name` (`stored_name`),
|
||||
KEY `idx_files_deleted_at` (`deleted_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 2) 数据迁移:attachments 全部行(含软删除)复制进 files,type='attachments'
|
||||
INSERT INTO `files`
|
||||
(`id`,`type`,`article_id`,`session_token`,`uploader_id`,`filename`,`stored_name`,`ext`,`mime`,`size`,`category`,`created_at`,`updated_at`,`deleted_at`)
|
||||
SELECT
|
||||
a.`id`, 'attachments', a.`article_id`, a.`session_token`, a.`uploader_id`, a.`filename`,
|
||||
a.`stored_name`, a.`ext`, a.`mime`, a.`size`, a.`category`, a.`created_at`, a.`updated_at`, a.`deleted_at`
|
||||
FROM `attachments` a
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `files` f WHERE f.`id` = a.`id`);
|
||||
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)"
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Migration: 删除旧 attachments 表(数据已迁移至统一 files 表,type='attachments')
|
||||
-- Date: 2026-08-28
|
||||
-- 前置条件:已部署使用 files 表的新版 blog_go 并完成验证(启动迁移会把
|
||||
-- 尚未搬运的 attachments 行复制进 files)。本脚本幂等,可安全重跑。
|
||||
--
|
||||
-- 执行前确认:
|
||||
-- SELECT COUNT(*) AS remaining FROM attachments a
|
||||
-- WHERE NOT EXISTS (SELECT 1 FROM files f WHERE f.id = a.id);
|
||||
-- 结果应为 0——若有遗留行,先重启新版本服务让其自动搬运。
|
||||
|
||||
DROP TABLE IF EXISTS `attachments`;
|
||||
+1915
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,134 @@
|
||||
// 文章附件区共享逻辑(管理员与普通用户的文章新建/编辑页共用):
|
||||
// 上传(multipart → 附件接口,写 files 表 type=attachments)、编辑页回填列表、
|
||||
// 插入正文(图片 ![]() / 其他 []())、图片一键设封面、删除(引用计数由服务端处理)。
|
||||
//
|
||||
// 由页面调用:initArticleAttachments(cfg)
|
||||
// cfg:
|
||||
// uploadURL 上传接口,如 "/api/my/articles/attachments"(DELETE 为 uploadURL + "/<id>")
|
||||
// listURL 列表接口模板,":id" 会被替换为文章 id,如 "/api/my/articles/:id/attachments"
|
||||
// editor EasyMDE 实例(用于在光标处插入 Markdown)
|
||||
// articleID 文章 id(编辑页);新建页为 0
|
||||
// sessionToken 新建页的临时归属令牌
|
||||
// texts 页面 i18n 文案:{pick, uploading, insert, setCover, coverSet, del, delConfirm, err}
|
||||
window.initArticleAttachments = function (cfg) {
|
||||
var uploadBtn = document.getElementById('attachmentUploadBtn');
|
||||
var fileInput = document.getElementById('attachmentInput');
|
||||
var msgEl = document.getElementById('attachmentMsg');
|
||||
var listEl = document.getElementById('attachmentList');
|
||||
var texts = cfg.texts || {};
|
||||
if (!uploadBtn || !listEl || !fileInput) { return; }
|
||||
|
||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||
var csrfToken = meta ? meta.getAttribute('content') : '';
|
||||
|
||||
// Enable the uploader (uploads allowed only while logged in, which is true here).
|
||||
uploadBtn.disabled = false;
|
||||
fileInput.disabled = false;
|
||||
|
||||
function fmtSize(b) {
|
||||
if (b < 1024) { return b + ' B'; }
|
||||
var u = ['KiB', 'MiB', 'GiB'], i = -1;
|
||||
do { b /= 1024; i++; } while (b >= 1024 && i < u.length - 1);
|
||||
return b.toFixed(1) + ' ' + u[i];
|
||||
}
|
||||
|
||||
function insertMd(md) {
|
||||
var cm = cfg.editor && cfg.editor.codemirror;
|
||||
if (!cm) { return; }
|
||||
cm.replaceSelection(md + '\n');
|
||||
cm.focus();
|
||||
}
|
||||
|
||||
function addRow(att) {
|
||||
var tr = document.createElement('tr');
|
||||
tr.className = 'hover:bg-gray-50';
|
||||
tr.dataset.id = att.id;
|
||||
var nameTd = document.createElement('td');
|
||||
nameTd.className = 'px-3 py-2 text-sm text-gray-800';
|
||||
var link = document.createElement('a');
|
||||
link.href = att.url; link.target = '_blank'; link.textContent = att.filename;
|
||||
nameTd.appendChild(link);
|
||||
var sizeTd = document.createElement('td');
|
||||
sizeTd.className = 'px-3 py-2 text-sm text-gray-500';
|
||||
sizeTd.textContent = fmtSize(att.size);
|
||||
var actTd = document.createElement('td');
|
||||
actTd.className = 'px-3 py-2 text-sm text-right whitespace-nowrap';
|
||||
var insBtn = document.createElement('button');
|
||||
insBtn.type = 'button';
|
||||
insBtn.textContent = texts.insert || 'Insert';
|
||||
insBtn.className = 'text-blue-600 hover:text-blue-800 font-medium mr-3 cursor-pointer bg-transparent border-none';
|
||||
insBtn.onclick = function () {
|
||||
var md = att.is_image
|
||||
? ''
|
||||
: '[' + att.filename + '](' + att.url + ')';
|
||||
insertMd(md);
|
||||
};
|
||||
|
||||
// Images: extra button to copy the URL into the cover field.
|
||||
var coverBtn = null;
|
||||
if (att.is_image) {
|
||||
coverBtn = document.createElement('button');
|
||||
coverBtn.type = 'button';
|
||||
coverBtn.textContent = texts.setCover || 'Cover';
|
||||
coverBtn.className = 'text-green-600 hover:text-green-800 font-medium mr-3 cursor-pointer bg-transparent border-none';
|
||||
coverBtn.onclick = function () {
|
||||
var cover = document.querySelector('input[name="cover"]');
|
||||
if (cover) { cover.value = att.url; msgEl.textContent = texts.coverSet || ''; }
|
||||
};
|
||||
}
|
||||
var delBtn = document.createElement('button');
|
||||
delBtn.type = 'button';
|
||||
delBtn.textContent = texts.del || 'Delete';
|
||||
delBtn.className = 'text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none';
|
||||
delBtn.onclick = function () {
|
||||
if (!confirm(texts.delConfirm || 'Delete?')) { return; }
|
||||
fetch(cfg.uploadURL + '/' + att.id, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrfToken }
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (r.ok) { tr.remove(); }
|
||||
else { msgEl.textContent = r.error || 'error'; }
|
||||
});
|
||||
};
|
||||
actTd.appendChild(insBtn);
|
||||
if (coverBtn) { actTd.appendChild(coverBtn); }
|
||||
actTd.appendChild(delBtn);
|
||||
tr.appendChild(nameTd);
|
||||
tr.appendChild(sizeTd);
|
||||
tr.appendChild(actTd);
|
||||
listEl.appendChild(tr);
|
||||
}
|
||||
|
||||
uploadBtn.addEventListener('click', function () {
|
||||
if (!fileInput.files.length) { msgEl.textContent = texts.pick || 'Pick a file.'; return; }
|
||||
var fd = new FormData();
|
||||
fd.append('file', fileInput.files[0]);
|
||||
if (cfg.articleID) { fd.append('article_id', cfg.articleID); }
|
||||
else if (cfg.sessionToken) { fd.append('session_token', cfg.sessionToken); }
|
||||
msgEl.textContent = texts.uploading || 'Uploading...';
|
||||
fetch(cfg.uploadURL, {
|
||||
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; }
|
||||
msgEl.textContent = '';
|
||||
addRow(r);
|
||||
fileInput.value = '';
|
||||
})
|
||||
.catch(function () { msgEl.textContent = texts.err || 'Upload failed.'; });
|
||||
});
|
||||
|
||||
// Edit page: load existing attachments.
|
||||
if (cfg.articleID) {
|
||||
fetch(cfg.listURL.replace(':id', cfg.articleID))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
(r.attachments || []).forEach(addRow);
|
||||
});
|
||||
}
|
||||
};
|
||||
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
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 434 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
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
@@ -1,251 +0,0 @@
|
||||
{{define "article_create"}}
|
||||
{{template "header" .}}
|
||||
{{template "markdown_assets" .}}
|
||||
|
||||
<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">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="{{.FormAction}}" method="post" class="space-y-6">
|
||||
<!-- Hidden: attachment ownership (token on create, id on edit) -->
|
||||
<input type="hidden" name="session_token" value="{{.SessionToken}}">
|
||||
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_title"}}</label>
|
||||
<input type="text" name="title" value="{{.FormTitle}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="{{index .Tr "article_title"}}">
|
||||
</div>
|
||||
|
||||
<!-- Slug -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_slug"}}</label>
|
||||
<input type="text" name="slug" value="{{.FormSlug}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="{{index .Tr "article_slug_hint"}}">
|
||||
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_slug_hint"}}</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_summary"}}</label>
|
||||
<textarea name="summary" rows="3"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors resize-y"
|
||||
placeholder="{{index .Tr "article_summary"}}">{{.FormSummary}}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Content (EasyMDE Markdown Editor) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_content"}}</label>
|
||||
<textarea id="articleContent" name="content">{{.FormContent}}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Cover -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_cover"}}</label>
|
||||
<input type="text" name="cover" value="{{.FormCover}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="https://...">
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_tags"}}</label>
|
||||
<input type="text" name="tags" value="{{.FormTags}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="{{index .Tr "article_tags_hint"}}">
|
||||
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_tags_hint"}}</p>
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_attachments"}}</label>
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<input type="file" id="attachmentInput" class="text-sm text-gray-600" disabled>
|
||||
<button type="button" id="attachmentUploadBtn"
|
||||
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors cursor-pointer disabled:opacity-50"
|
||||
disabled>
|
||||
{{index .Tr "article_upload"}}
|
||||
</button>
|
||||
<span id="attachmentMsg" class="text-xs text-gray-400"></span>
|
||||
</div>
|
||||
<table class="w-full text-left border border-gray-200 rounded-lg overflow-hidden">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-xs font-semibold text-gray-600">{{index .Tr "article_att_name"}}</th>
|
||||
<th class="px-3 py-2 text-xs font-semibold text-gray-600">{{index .Tr "article_att_size"}}</th>
|
||||
<th class="px-3 py-2 text-xs font-semibold text-gray-600 text-right">{{index .Tr "settings_actions"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="attachmentList" class="divide-y divide-gray-100"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Published At -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_published_at"}}</label>
|
||||
<input type="datetime-local" name="published_at" value="{{.FormPublishedAt}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
||||
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_published_at_hint"}}</p>
|
||||
</div>
|
||||
|
||||
<!-- IsTop -->
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" name="is_top" value="1" id="isTopCheckbox" {{if .FormIsTop}}checked{{end}}
|
||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||||
<label for="isTopCheckbox" class="text-sm font-medium text-gray-700">{{index .Tr "article_is_top"}}</label>
|
||||
</div>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" name="status" value="0"
|
||||
class="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors cursor-pointer">
|
||||
{{index .Tr "article_save_draft"}}
|
||||
</button>
|
||||
<button type="submit" name="status" value="1"
|
||||
class="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors cursor-pointer">
|
||||
{{index .Tr "article_publish"}}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{{template "footer" .}}
|
||||
|
||||
<script>
|
||||
var easyMDE = new EasyMDE({
|
||||
element: document.getElementById('articleContent'),
|
||||
spellChecker: false,
|
||||
autosave: { enabled: false },
|
||||
placeholder: '{{index .Tr "article_content"}}',
|
||||
previewRender: function (plainText, preview) {
|
||||
return '<div class="md-body">' + BlogMD.render(plainText) + '</div>';
|
||||
},
|
||||
toolbar: [
|
||||
'bold', 'italic', 'heading', '|',
|
||||
'quote', 'unordered-list', 'ordered-list', '|',
|
||||
'link', 'image', 'code', 'table', '|',
|
||||
'preview', 'side-by-side', 'fullscreen', '|',
|
||||
'guide'
|
||||
],
|
||||
status: false,
|
||||
minHeight: '300px'
|
||||
});
|
||||
|
||||
// ---- Attachments ----
|
||||
(function () {
|
||||
var articleID = {{ if .FormArticleID }}{{ .FormArticleID }}{{ else }}0{{ end }};
|
||||
var sessionToken = "{{ .SessionToken }}";
|
||||
var uploadBtn = document.getElementById('attachmentUploadBtn');
|
||||
var fileInput = document.getElementById('attachmentInput');
|
||||
var msgEl = document.getElementById('attachmentMsg');
|
||||
var listEl = document.getElementById('attachmentList');
|
||||
|
||||
// Enable the uploader (uploads allowed only while logged in, which is true here).
|
||||
uploadBtn.disabled = false;
|
||||
fileInput.disabled = false;
|
||||
|
||||
function fmtSize(b) {
|
||||
if (b < 1024) return b + ' B';
|
||||
var u = ['KiB', 'MiB', 'GiB'], i = -1;
|
||||
do { b /= 1024; i++; } while (b >= 1024 && i < u.length - 1);
|
||||
return b.toFixed(1) + ' ' + u[i];
|
||||
}
|
||||
|
||||
function addRow(att) {
|
||||
var tr = document.createElement('tr');
|
||||
tr.className = 'hover:bg-gray-50';
|
||||
tr.dataset.id = att.id;
|
||||
var nameTd = document.createElement('td');
|
||||
nameTd.className = 'px-3 py-2 text-sm text-gray-800';
|
||||
var link = document.createElement('a');
|
||||
link.href = att.url; link.target = '_blank'; link.textContent = att.filename;
|
||||
nameTd.appendChild(link);
|
||||
var sizeTd = document.createElement('td');
|
||||
sizeTd.className = 'px-3 py-2 text-sm text-gray-500';
|
||||
sizeTd.textContent = fmtSize(att.size);
|
||||
var actTd = document.createElement('td');
|
||||
actTd.className = 'px-3 py-2 text-sm text-right whitespace-nowrap';
|
||||
var insBtn = document.createElement('button');
|
||||
insBtn.type = 'button';
|
||||
insBtn.textContent = "{{index .Tr "article_att_insert"}}";
|
||||
insBtn.className = 'text-blue-600 hover:text-blue-800 font-medium mr-3 cursor-pointer bg-transparent border-none';
|
||||
insBtn.onclick = function () {
|
||||
var md = att.is_image
|
||||
? ''
|
||||
: '[' + att.filename + '](' + att.url + ')';
|
||||
var cm = easyMDE.codemirror;
|
||||
cm.replaceSelection(md + '\n');
|
||||
cm.focus();
|
||||
};
|
||||
|
||||
// Images: extra button to copy the URL into the cover field.
|
||||
var coverBtn = null;
|
||||
if (att.is_image) {
|
||||
coverBtn = document.createElement('button');
|
||||
coverBtn.type = 'button';
|
||||
coverBtn.textContent = "{{index .Tr "article_att_set_cover"}}";
|
||||
coverBtn.className = 'text-green-600 hover:text-green-800 font-medium mr-3 cursor-pointer bg-transparent border-none';
|
||||
coverBtn.onclick = function () {
|
||||
var cover = document.querySelector('input[name="cover"]');
|
||||
if (cover) { cover.value = att.url; msgEl.textContent = "{{index .Tr "article_att_cover_set"}}"; }
|
||||
};
|
||||
}
|
||||
var delBtn = document.createElement('button');
|
||||
delBtn.type = 'button';
|
||||
delBtn.textContent = "{{index .Tr "settings_delete"}}";
|
||||
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' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (r.ok) { tr.remove(); }
|
||||
else { msgEl.textContent = r.error || 'error'; }
|
||||
});
|
||||
};
|
||||
actTd.appendChild(insBtn);
|
||||
if (coverBtn) { actTd.appendChild(coverBtn); }
|
||||
actTd.appendChild(delBtn);
|
||||
tr.appendChild(nameTd);
|
||||
tr.appendChild(sizeTd);
|
||||
tr.appendChild(actTd);
|
||||
listEl.appendChild(tr);
|
||||
}
|
||||
|
||||
uploadBtn.addEventListener('click', function () {
|
||||
if (!fileInput.files.length) { msgEl.textContent = "{{index .Tr "article_att_pick"}}"; return; }
|
||||
var fd = new FormData();
|
||||
fd.append('file', fileInput.files[0]);
|
||||
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 })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (r.error) { msgEl.textContent = r.error; return; }
|
||||
msgEl.textContent = '';
|
||||
addRow(r);
|
||||
fileInput.value = '';
|
||||
})
|
||||
.catch(function () { msgEl.textContent = "{{index .Tr "article_att_error"}}"; });
|
||||
});
|
||||
|
||||
// Edit page: load existing attachments.
|
||||
if (articleID) {
|
||||
fetch('/admin/articles/' + articleID + '/attachments')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
(r.attachments || []).forEach(addRow);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</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>
|
||||
@@ -91,6 +95,19 @@
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// 评论审核操作(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>
|
||||
|
||||
+138
-14
@@ -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,14 @@
|
||||
{{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">
|
||||
<link rel="stylesheet" href="/static/vendor/font-awesome/4.7.0/css/font-awesome.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 -->
|
||||
@@ -54,7 +58,7 @@
|
||||
<!-- Avatar Dropdown (click to toggle) -->
|
||||
<div class="relative" id="avatarDropdown">
|
||||
<button type="button" id="avatarBtn" class="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer" onclick="toggleDropdown()">
|
||||
<div class="w-8 h-8 rounded-full overflow-hidden border-2 border-gray-300 hover:border-blue-500 transition-colors flex items-center justify-center bg-gray-200 text-gray-600 font-bold text-sm">
|
||||
<div id="headerAvatarWrap" class="w-8 h-8 rounded-full overflow-hidden border-2 border-gray-300 hover:border-blue-500 transition-colors flex items-center justify-center bg-gray-200 text-gray-600 font-bold text-sm">
|
||||
{{if .Avatar}}
|
||||
<img src="/uploads/avatars/{{.Avatar}}" alt="avatar" class="w-full h-full object-cover">
|
||||
{{else}}
|
||||
@@ -79,7 +83,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 +116,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 +174,125 @@
|
||||
<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 };
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 上传本地图片(multipart)到文章附件接口(files 表,type=attachments)。
|
||||
// 新建页传 session_token,编辑页传 article_id;成功 resolve {…, url, is_image}。
|
||||
// 供文章新建/编辑页编辑器“上传图片”按钮使用。
|
||||
window.blogUploadImage = function (opts) {
|
||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||
var csrf = meta ? meta.getAttribute('content') : '';
|
||||
var fd = new FormData();
|
||||
fd.append('file', opts.file);
|
||||
if (opts.articleID) { fd.append('article_id', opts.articleID); }
|
||||
if (!opts.articleID && opts.sessionToken) { fd.append('session_token', opts.sessionToken); }
|
||||
return fetch(opts.url, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf, 'Accept': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: fd
|
||||
}).then(function (r) {
|
||||
return r.json();
|
||||
});
|
||||
};
|
||||
|
||||
// 将表单序列化为 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');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user