Files
go_blog/handlers/article.go
T
kevinandClaude Fable 5 1f1123acf8 fix: generate non-empty slugs for non-ASCII titles
The slug regex stripped everything that is not a-z0-9, so Chinese or
punctuation-only titles produced an empty slug (stored as ""), which
broke the unique index and article URLs.

generateSlug now keeps ASCII word characters and returns "" when the
title yields no URL-safe ASCII (e.g. CJK or punctuation only). Both the
create and update handlers fall back to a "post-<id>" slug in that case;
on create a temporary token-based placeholder is refined to post-<id>
once the row exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-21 21:14:23 +08:00

359 lines
10 KiB
Go

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 matches runs of non-word characters (anything that is not a
// letter or digit); these become single dashes.
var slugSepRe = regexp.MustCompile(`[^\p{L}\p{N}]+`)
// slugDashRe matches runs of dashes to collapse.
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.
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.
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.
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 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.
func fallbackSlug(id uint) string {
if id > 0 {
return fmt.Sprintf("post-%d", id)
}
return "post-" + randomToken()[:8]
}
// articleForm holds the parsed article form fields, shared by the create and
// edit handlers and their validation-error repopulation paths.
type articleForm struct {
Title string
Slug string
Summary string
Content string
Cover string
StatusStr string
IsTop bool
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)
}
// 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",
}
}
// applyFormToData writes the form field values into the template data map so
// the form is repopulated on render (initial load or validation error).
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["FormAction"] = f.Action
data["FormTitleText"] = f.TitleText
data["FormArticleID"] = f.ArticleID
data["SessionToken"] = f.SessionToken
}
// renderArticleForm renders the shared article form template with the given
// form values and optional error message.
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 extracts the logged-in user's ID from the session, defending
// against int/uint/int64/float64 storage. Returns ok=false if absent.
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 parses the status string ("0" draft, "1" published) and
// returns the model status constant, defaulting to draft.
func statusFromForm(statusStr string) int {
if statusStr == "1" {
return models.ArticlePublished
}
return models.ArticleDraft
}
// ArticleCreatePage renders the article creation form.
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 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"))
// Validate required fields.
if f.Title == "" {
renderArticleForm(c, db, f, tr["article_title_required"])
return
}
if f.Content == "" {
renderArticleForm(c, db, f, tr["article_content_required"])
return
}
// Auto-generate slug if empty.
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.
if f.Slug == "" {
f.Slug = fallbackSlug(0)
}
}
status := statusFromForm(f.StatusStr)
authorID, ok := sessionAuthorID(c)
if !ok {
c.Redirect(http.StatusFound, "/login")
return
}
var publishedAt *time.Time
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,
PublishedAt: publishedAt,
}
if err := db.Create(&article).Error; err != nil {
renderArticleForm(c, db, f, tr["article_error"])
return
}
// Refine a token-based placeholder slug to the readable post-<id> form.
if strings.HasPrefix(f.Slug, "post-") && len(f.Slug) > 9 {
if newSlug := fallbackSlug(article.ID); newSlug != "" {
db.Model(&article).Update("slug", newSlug)
}
}
// Bind any attachments uploaded during creation (plan A: pending rows
// owned by session_token, article_id=0).
if f.SessionToken != "" {
_ = BindPendingAttachments(db, f.SessionToken, article.ID)
}
// Success: redirect to dashboard.
c.Redirect(http.StatusFound, "/admin")
}
}
// ArticleListPage renders the admin article management list.
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 renders the shared form prefilled with an existing article.
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
}
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,
Action: "/admin/articles/" + id + "/edit",
TitleText: tr["article_edit_title"],
ArticleID: article.ID,
}, "")
}
}
// 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")
var article models.Article
if err := db.First(&article, "id = ?", id).Error; err != nil {
c.Redirect(http.StatusFound, "/admin/articles")
return
}
f := parseArticleForm(c)
f.Action = "/admin/articles/" + id + "/edit"
f.TitleText = tr["article_edit_title"]
if f.Title == "" {
renderArticleForm(c, db, f, tr["article_title_required"])
return
}
if f.Content == "" {
renderArticleForm(c, db, f, tr["article_content_required"])
return
}
if f.Slug == "" {
f.Slug = generateSlug(f.Title)
if f.Slug == "" {
f.Slug = fallbackSlug(article.ID)
}
}
newStatus := statusFromForm(f.StatusStr)
// Stamp the publish time the first time an article is published.
wasPublished := article.Status == models.ArticlePublished
var publishedAt *time.Time = 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,
"PublishedAt": publishedAt,
}
if err := db.Model(&article).Updates(updates).Error; err != nil {
renderArticleForm(c, db, f, tr["article_error"])
return
}
c.Redirect(http.StatusFound, "/admin/articles")
}
}
// ArticleDelete soft-deletes an article (GORM fills DeletedAt) and redirects
// back to the management list.
func ArticleDelete(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
db.Delete(&models.Article{}, "id = ?", id)
c.Redirect(http.StatusFound, "/admin/articles")
}
}