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>
359 lines
10 KiB
Go
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")
|
|
}
|
|
}
|