fix: SECURITY_TODO #31 普通作者不可置顶全站文章
任意注册作者可经 /api/my/articles 提交 is_top=true 把文章钉在全站 首页最顶端(publishedArticleOrder 为 is_top DESC 优先),属影响公共 展示位的横向越权(确认非设计意图,予收紧)。 - handlers/article.go: 拆出共享实现 articleCreate/articleUpdate, 增加 allowIsTop 开关;admin 路径保持 true - handlers/my_articles.go: 作者创建强制 is_top=false;编辑保留库中 现有值(管理员授权的置顶不因作者编辑而丢失,作者也无法自行取消) - templates/user/my_article_form.html: 移除置顶复选框 - 测试: TestMyArticlesCannotPin(作者 create/update is_top=true → 落库 false;admin 路径可置顶;作者编辑不丢置顶)
This commit is contained in:
+5
-5
@@ -209,13 +209,13 @@
|
||||
- [x] SQLite 无 FOR UPDATE(且纯 Go 驱动连接池可并发读):叠加进程内互斥锁 `lastAdminMu`(应用按设计单实例部署,见 #10 限流器注释)闭合同进程竞态;事务内计数在写锁串行化后重读
|
||||
- **验证**: ✅ `TestConcurrentLastAdminDowngrade`(两位管理员并发降级:恰好 1 成功 1 拒绝,最终管理员 ≥1,`-race` 通过)
|
||||
|
||||
### [ ] 31. 普通作者可置顶全站文章——需确认设计意图(2026-08-27 API 化复审新发现)
|
||||
### [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` 影响排序。若"作者可置顶自己的文章"非产品预期,属影响公共展示位的横向越权。
|
||||
- **修复**(若确认非设计意图):
|
||||
- [ ] MyArticleCreate / MyArticleUpdate 强制 `is_top=false`(仅 admin 路径接受该字段)
|
||||
- [ ] my_article_form.html 移除置顶复选框
|
||||
- **验证**: [ ] 测试:作者经 /api/my/articles 提交 is_top=true → 落库为 false;admin 路径不受影响
|
||||
- **修复**(确认非设计意图,予收紧):
|
||||
- [x] MyArticleCreate/MyArticleUpdate 经共享实现 `articleCreate/articleUpdate(..., allowIsTop=false)` 强制 `is_top=false`;MyArticleUpdate 保留库中现有值(管理员授权的置顶不因作者编辑而丢失/取消)
|
||||
- [x] my_article_form.html 移除置顶复选框(作者表单不再提供该字段)
|
||||
- **验证**: ✅ `TestMyArticlesCannotPin`(作者 create/update 提交 is_top=true → 落库 false;admin 路径可正常置顶;作者编辑已置顶文章不丢失置顶)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+14
-2
@@ -267,6 +267,12 @@ func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc {
|
||||
// ArticleCreate 处理创建新文章的 POST 请求(admin 与 my 共用)。
|
||||
// redirectPath 是成功跳转目标(admin 用 /admin,普通用户用 /my/articles)。
|
||||
func ArticleCreate(db *gorm.DB, redirectPath string) gin.HandlerFunc {
|
||||
return articleCreate(db, redirectPath, true)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -319,7 +325,7 @@ func ArticleCreate(db *gorm.DB, redirectPath string) gin.HandlerFunc {
|
||||
Content: f.Content,
|
||||
Cover: f.Cover,
|
||||
Status: status,
|
||||
IsTop: f.IsTop,
|
||||
IsTop: f.IsTop && allowIsTop,
|
||||
PublishedAt: publishedAt,
|
||||
}
|
||||
|
||||
@@ -401,6 +407,12 @@ func ArticleEditPage(db *gorm.DB) gin.HandlerFunc {
|
||||
// ArticleUpdate 处理更新现有文章的请求(admin 与 my 共用)。
|
||||
// redirectPath 是成功跳转目标(admin 用 /admin/articles,普通用户用 /my/articles)。
|
||||
func ArticleUpdate(db *gorm.DB, redirectPath string) gin.HandlerFunc {
|
||||
return articleUpdate(db, redirectPath, true)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -459,7 +471,7 @@ func ArticleUpdate(db *gorm.DB, redirectPath string) gin.HandlerFunc {
|
||||
"Content": f.Content,
|
||||
"Cover": f.Cover,
|
||||
"Status": newStatus,
|
||||
"IsTop": f.IsTop,
|
||||
"IsTop": f.IsTop && allowIsTop,
|
||||
"PublishedAt": publishedAt,
|
||||
}
|
||||
|
||||
|
||||
+12
-8
@@ -47,8 +47,9 @@ func MyArticleCreatePage(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// MyArticleCreate 处理普通用户创建新文章的 POST 请求。
|
||||
// 复用创建逻辑,普通作者不可置顶(SECURITY_TODO #31)。
|
||||
func MyArticleCreate(db *gorm.DB) gin.HandlerFunc {
|
||||
return ArticleCreate(db, "/my/articles") // 复用创建逻辑,跳转到 my 列表
|
||||
return articleCreate(db, "/my/articles", false)
|
||||
}
|
||||
|
||||
// MyArticleEditPage 为已登录用户自己的文章渲染编辑表单。
|
||||
@@ -137,13 +138,16 @@ 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -64,14 +64,6 @@
|
||||
<option value="1" {{if eq .FormStatus "1"}}selected{{end}}>{{index .Tr "article_published"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="checkbox" name="is_top" value="1" {{if .FormIsTop}}checked{{end}}
|
||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||||
<span class="ml-2 text-sm font-medium text-gray-700">{{index .Tr "article_field_is_top"}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
|
||||
Reference in New Issue
Block a user