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:
2026-06-21 21:02:09 +08:00
co-authored by Claude Fable 5
parent 0c5bcde7da
commit e872c08f3b
7 changed files with 486 additions and 13 deletions
+38 -12
View File
@@ -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,
}, "")
}
}