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-" 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-"). 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- 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- 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") } }