feat: article attachments with content-addressed dedup
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>
This commit is contained in:
+38
-12
@@ -1,6 +1,8 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -17,6 +19,17 @@ import (
|
||||
// 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)
|
||||
@@ -31,15 +44,17 @@ func generateSlug(title string) string {
|
||||
// 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)
|
||||
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.
|
||||
@@ -67,6 +82,8 @@ func applyFormToData(data gin.H, f articleForm) {
|
||||
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
|
||||
@@ -117,9 +134,10 @@ 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",
|
||||
Action: "/admin/articles/new",
|
||||
TitleText: tr["article_create_title"],
|
||||
StatusStr: "0",
|
||||
SessionToken: randomToken(),
|
||||
}, "")
|
||||
}
|
||||
}
|
||||
@@ -131,6 +149,7 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc {
|
||||
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 == "" {
|
||||
@@ -178,6 +197,12 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc {
|
||||
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")
|
||||
}
|
||||
@@ -218,6 +243,7 @@ func ArticleEditPage(db *gorm.DB) gin.HandlerFunc {
|
||||
IsTop: article.IsTop,
|
||||
Action: "/admin/articles/" + id + "/edit",
|
||||
TitleText: tr["article_edit_title"],
|
||||
ArticleID: article.ID,
|
||||
}, "")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user