Files
go_blog/handlers/article.go
T
dsh 7e6dd0cc02 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 路径可置顶;作者编辑不丢置顶)
2026-08-27 21:46:27 +08:00

505 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"go_blog/models"
"gorm.io/gorm"
)
// slugSepRe 匹配连续的非单词字符(任何非字母或数字的字符);
// 这些字符会被替换为单个连字符。
var slugSepRe = regexp.MustCompile(`[^\p{L}\p{N}]+`)
// slugDashRe 匹配连续连字符,用于折叠。
var slugDashRe = regexp.MustCompile(`-{2,}`)
// slugAsciiRe 匹配仅由 URL 安全的 ASCII 字母、数字和连字符组成的 slug。
// 包含其他字符的 slug(如 CJK,或类似土耳其无点 i 的 Unicode 小写
// 癖好)在 URL 中不稳定,会被丢弃,转而使用 "post-<id>" 回退方案。
var slugAsciiRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
// randomToken 返回 32 字节十六进制令牌,用于在文章保存前于创建页面上
// 持有待处理的附件归属。
func randomToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// 极罕见;回退到基于时间的值。
return strconv.FormatInt(time.Now().UnixNano(), 16)
}
return hex.EncodeToString(b)
}
// 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, "-")
s = strings.Trim(s, "-")
s = slugDashRe.ReplaceAllString(s, "-")
if !slugAsciiRe.MatchString(s) {
return ""
}
return s
}
// fallbackSlug 在由标题派生的 slug 为空时返回非空 slug
// (例如标题只包含标点/空白,或全部被剔除)。可用时使用文章 ID,
// 否则使用短令牌。
func fallbackSlug(id uint) string {
if id > 0 {
return fmt.Sprintf("post-%d", id)
}
return "post-" + randomToken()[:8]
}
// articleForm 是文章创建/更新接口的 JSON 请求体。
// Action/TitleText/ArticleID 仅由页面渲染路径使用,不参与 JSON 绑定。
type articleForm struct {
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
}
// 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 将表单字段值写入模板数据映射,使渲染时表单被重新填充
// (初次加载或校验错误)。
func applyFormToData(data gin.H, f articleForm) {
data["FormTitle"] = f.Title
data["FormSlug"] = f.Slug
data["FormSummary"] = f.Summary
data["FormContent"] = f.Content
data["FormCover"] = f.Cover
data["FormStatus"] = f.StatusStr
data["FormIsTop"] = f.IsTop
data["FormPublishedAt"] = f.PublishedAt
data["FormTags"] = f.Tags
data["FormAction"] = f.Action
data["FormTitleText"] = f.TitleText
data["FormArticleID"] = f.ArticleID
data["SessionToken"] = f.SessionToken
}
// renderArticleForm 使用给定的表单值和可选的错误消息渲染共享的文章表单模板。
func renderArticleForm(c *gin.Context, db *gorm.DB, f articleForm, errMsg string) {
data := DefaultData(c)
data["Title"] = f.TitleText
if errMsg != "" {
data["Error"] = errMsg
}
applyFormToData(data, f)
c.HTML(http.StatusOK, "article_create", data)
}
// sessionAuthorID 从会话中提取已登录用户的 ID,兼容 int/uint/int64/float64
// 存储类型。不存在时返回 ok=false。
func sessionAuthorID(c *gin.Context) (uint, bool) {
session := sessions.Default(c)
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
}
}
// statusFromForm 解析状态字符串("0" 草稿、"1" 已发布),
// 返回模型状态常量,默认为草稿。
func statusFromForm(statusStr string) int {
if statusStr == "1" {
return models.ArticlePublished
}
return models.ArticleDraft
}
// parsePublishedAt 将表单中的 datetime-local 格式("2006-01-02T15:04")解析为
// time.Time 指针。字符串为空或非法时返回 nil。
func parsePublishedAt(publishedAtStr string) *time.Time {
if publishedAtStr == "" {
return nil
}
// datetime-local 格式:"2006-01-02T15:04"
t, err := time.ParseInLocation("2006-01-02T15:04", publishedAtStr, time.Local)
if err != nil {
return nil
}
return &t
}
// formatPublishedAt 将 time.Time 指针格式化为表单所需的 datetime-local 格式。
// 指针为 nil 时返回空字符串。
func formatPublishedAt(t *time.Time) string {
if t == nil {
return ""
}
return t.Local().Format("2006-01-02T15:04")
}
// parseTags 按逗号拆分标签字符串并返回标签名。
func parseTags(tagStr string) []string {
if tagStr == "" {
return []string{}
}
parts := strings.Split(tagStr, ",")
var tags []string
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
tags = append(tags, trimmed)
}
}
return tags
}
// syncArticleTags 将标签与文章关联(查找或创建标签)。
func syncArticleTags(db *gorm.DB, article *models.Article, tagNames []string) error {
// 清除现有标签
if err := db.Model(article).Association("Tags").Clear(); err != nil {
return err
}
// 若没有标签,则完成
if len(tagNames) == 0 {
return nil
}
// 查找或创建每个标签并关联到文章
var tags []models.Tag
for _, name := range tagNames {
tag, err := models.FindOrCreateTag(db, name, name)
if err != nil {
return err
}
if tag != nil {
tags = append(tags, *tag)
}
}
// 将标签关联到文章
if len(tags) > 0 {
if err := db.Model(article).Association("Tags").Append(tags); err != nil {
return err
}
}
// 更新标签计数
for _, tag := range tags {
models.UpdateTagCount(db, tag.ID)
}
return nil
}
// formatArticleTags 将文章标签转换为逗号分隔的字符串以填充表单。
func formatArticleTags(tags []models.Tag) string {
if len(tags) == 0 {
return ""
}
var names []string
for _, tag := range tags {
names = append(names, tag.NameZh)
}
return strings.Join(names, ", ")
}
// ArticleCreatePage 渲染文章创建表单。
func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
renderArticleForm(c, db, articleForm{
Action: "/admin/articles/new",
TitleText: tr["article_create_title"],
StatusStr: "0",
SessionToken: randomToken(),
}, "")
}
}
// 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 时普通作者的置顶请求
// 被降级为 falseSECURITY_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 == "" {
APIError(c, http.StatusBadRequest, "article_title_required")
return
}
if f.Content == "" {
APIError(c, http.StatusBadRequest, "article_content_required")
return
}
// 为空时自动生成 slug。
if f.Slug == "" {
f.Slug = generateSlug(f.Title)
// 标题没有可用字符(例如只有标点)。先使用临时的基于令牌的
// slug;插入后再完善为 post-<id>。
if f.Slug == "" {
f.Slug = fallbackSlug(0)
}
}
status := statusFromForm(f.StatusStr)
authorID, ok := sessionAuthorID(c)
if !ok {
APIError(c, http.StatusUnauthorized, "api_unauthorized")
return
}
var publishedAt *time.Time
if f.PublishedAt != "" {
// 用户提供了自定义发布时间
publishedAt = parsePublishedAt(f.PublishedAt)
} else if status == models.ArticlePublished {
// 未提供自定义时间而发布时,自动盖上当前时间
now := time.Now()
publishedAt = &now
}
article := models.Article{
AuthorID: authorID,
Title: f.Title,
Slug: f.Slug,
Summary: f.Summary,
Content: f.Content,
Cover: f.Cover,
Status: status,
IsTop: f.IsTop && allowIsTop,
PublishedAt: publishedAt,
}
if err := db.Create(&article).Error; err != nil {
APIError(c, http.StatusInternalServerError, "article_error")
return
}
// 将基于令牌的占位 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)
}
}
// 同步文章标签
tagNames := parseTags(f.Tags)
if err := syncArticleTags(db, &article, tagNames); err != nil {
// 记录错误但不影响文章创建
// 文章已经创建,标签是可选内容
}
// 绑定创建期间上传的任何附件(方案 A:由 session_token 持有、
// article_id=0 的待处理行)。
if f.SessionToken != "" {
_ = BindPendingAttachments(db, f.SessionToken, article.ID)
}
// 成功:跳转到文章列表。
APIOK(c, redirectPath, gin.H{"article_id": article.ID})
}
}
// ArticleListPage 渲染后台文章管理列表。
func ArticleListPage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
var articles []models.Article
db.Order("created_at DESC").Find(&articles)
data := DefaultData(c)
data["Title"] = tr["article_list_title"]
data["Articles"] = articles
c.HTML(http.StatusOK, "article_list", data)
}
}
// ArticleEditPage 渲染预填现有文章的共享表单。
func ArticleEditPage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
id := c.Param("id")
var article models.Article
if err := db.First(&article, "id = ?", id).Error; err != nil {
c.Redirect(http.StatusFound, "/admin/articles")
return
}
// 预加载文章标签
db.Model(&article).Association("Tags").Find(&article.Tags)
renderArticleForm(c, db, articleForm{
Title: article.Title,
Slug: article.Slug,
Summary: article.Summary,
Content: article.Content,
Cover: article.Cover,
StatusStr: strconv.Itoa(article.Status),
IsTop: article.IsTop,
PublishedAt: formatPublishedAt(article.PublishedAt),
Tags: formatArticleTags(article.Tags),
Action: "/admin/articles/" + id + "/edit",
TitleText: tr["article_edit_title"],
ArticleID: article.ID,
}, "")
}
}
// 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 {
APIError(c, http.StatusBadRequest, "api_invalid_request")
return
}
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 == "" {
APIError(c, http.StatusBadRequest, "article_title_required")
return
}
if f.Content == "" {
APIError(c, http.StatusBadRequest, "article_content_required")
return
}
if f.Slug == "" {
f.Slug = generateSlug(f.Title)
if f.Slug == "" {
f.Slug = fallbackSlug(article.ID)
}
}
newStatus := statusFromForm(f.StatusStr)
// 处理 published_at:若提供了表单值则使用,否则在首次发布时自动盖章。
var publishedAt *time.Time
if f.PublishedAt != "" {
// 用户提供了自定义发布时间
publishedAt = parsePublishedAt(f.PublishedAt)
} else {
// 文章首次发布时自动盖上发布时间。
wasPublished := article.Status == models.ArticlePublished
publishedAt = article.PublishedAt
if newStatus == models.ArticlePublished && !wasPublished && publishedAt == nil {
now := time.Now()
publishedAt = &now
}
}
updates := map[string]interface{}{
"Title": f.Title,
"Slug": f.Slug,
"Summary": f.Summary,
"Content": f.Content,
"Cover": f.Cover,
"Status": newStatus,
"IsTop": f.IsTop && allowIsTop,
"PublishedAt": publishedAt,
}
if err := db.Model(&article).Updates(updates).Error; err != nil {
APIError(c, http.StatusInternalServerError, "article_error")
return
}
// 同步文章标签
tagNames := parseTags(f.Tags)
if err := syncArticleTags(db, &article, tagNames); err != nil {
// 记录错误但不影响文章更新
}
APIOK(c, redirectPath, nil)
}
}
// ArticleDelete 软删除文章(GORM 填充 DeletedAt)。
func ArticleDelete(db *gorm.DB, redirectPath string) gin.HandlerFunc {
return func(c *gin.Context) {
id := parseUintParam(c, "id")
if id == 0 {
APIError(c, http.StatusBadRequest, "api_invalid_request")
return
}
db.Delete(&models.Article{}, id)
APIOK(c, redirectPath, nil)
}
}