Add an attachments table and AJAX upload/management on the article create and edit pages. - Attachment model with article_id (0 while pending on create), session_token ownership, and SHA-256 content-addressed stored_name - Upload validates via the platform policy (switch/type/size) and deduplicates on disk by content hash - Plan-A binding: attachments uploaded before an article exists are owned by a session token and bound to the new article on save - Delete soft-removes the record and drops the disk file only when no remaining rows reference it (reference counting for deduped files) - Edit page loads existing attachments via JSON list endpoint - Row actions: insert into body (markdown image/link), set as cover (image only), and delete - Download URLs use the configured default download base URL when set, else the local /uploads path Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
319 lines
8.5 KiB
Go
319 lines
8.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-contrib/sessions"
|
|
"github.com/gin-gonic/gin"
|
|
"go_blog/models"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// slugRe matches characters that are not URL-safe.
|
|
var slugRe = regexp.MustCompile(`[^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 slug.
|
|
func generateSlug(title string) string {
|
|
s := strings.ToLower(title)
|
|
s = strings.ReplaceAll(s, " ", "-")
|
|
s = slugRe.ReplaceAllString(s, "")
|
|
s = strings.Trim(s, "-")
|
|
// Collapse multiple dashes.
|
|
s = regexp.MustCompile(`-{2,}`).ReplaceAllString(s, "-")
|
|
return s
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|