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,
}, "")
}
}
+232
View File
@@ -0,0 +1,232 @@
package handlers
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"go_blog/models"
)
// attachmentsDir returns the on-disk directory for attachments under the given
// storage path, honoring the configured storage_dir.
func attachmentsDir(storagePath string) string {
dir := models.GetUploadConfig().StorageDir
if dir == "" {
dir = "attachments"
}
return filepath.Join(storagePath, dir)
}
// attachmentRelPath returns the path of an attachment relative to the storage
// root, e.g. "attachments/<stored>" — used to build /uploads URLs.
func attachmentRelPath(stored string) string {
dir := models.GetUploadConfig().StorageDir
if dir == "" {
dir = "attachments"
}
return dir + "/" + stored
}
// attachmentURL builds the public URL for an attachment: the default download
// base URL (if configured) joined with the relative path, else the local
// /uploads path served by the app.
func attachmentURL(stored string) string {
rel := attachmentRelPath(stored)
if base := models.DefaultDownloadBaseURL(); base != "" {
return strings.TrimRight(base, "/") + "/" + rel
}
return "/uploads/" + rel
}
// ---------------- Upload ----------------
// UploadAttachment handles AJAX attachment uploads from the article create/edit
// form. The request carries either a real article_id (edit page) or a
// session_token (create page, pending binding). Files are content-addressed by
// SHA-256 for on-disk deduplication.
func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
return func(c *gin.Context) {
uploaderID, ok := sessionAuthorID(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
articleID := parseUintForm(c, "article_id")
token := strings.TrimSpace(c.PostForm("session_token"))
// On the create page the article does not exist yet; require a token.
if articleID == 0 && token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing session_token"})
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no file provided"})
return
}
defer file.Close()
// Validate against the platform upload policy (switch + type + size).
check := ValidateUpload(header)
if !check.OK {
if !models.GetUploadConfig().Enabled {
c.JSON(http.StatusForbidden, gin.H{"error": "uploads are disabled"})
return
}
if check.Type != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("file too large; limit is %s", formatSize(check.MaxSize))})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "file type not allowed"})
return
}
// Read fully to compute the content hash.
content, err := io.ReadAll(file)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
return
}
sum := sha256.Sum256(content)
stored := hex.EncodeToString(sum[:])
// Deduplicate on disk: only write when the file is absent.
dir := attachmentsDir(storagePath)
if err := os.MkdirAll(dir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create storage dir"})
return
}
dstPath := filepath.Join(dir, stored)
if _, err := os.Stat(dstPath); os.IsNotExist(err) {
if err := os.WriteFile(dstPath, content, 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file"})
return
}
}
ext := strings.ToLower(filepath.Ext(header.Filename))
att := models.Attachment{
ArticleID: articleID,
SessionToken: token,
UploaderID: uploaderID,
Filename: header.Filename,
StoredName: stored,
Ext: ext,
MIME: header.Header.Get("Content-Type"),
Size: header.Size,
Category: check.Type.Category,
}
if err := db.Create(&att).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record attachment"})
return
}
c.JSON(http.StatusOK, gin.H{
"id": att.ID,
"filename": att.Filename,
"size": att.Size,
"category": att.Category,
"url": attachmentURL(att.StoredName),
"is_image": att.IsImage(),
})
}
}
// ---------------- Delete ----------------
// DeleteAttachment soft-deletes an attachment record and removes the on-disk
// file only when no remaining records reference it (reference counting, since
// content-addressed files may be shared).
func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
return func(c *gin.Context) {
id := parseUintParam(c, "id")
var att models.Attachment
if err := db.First(&att, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
stored := att.StoredName
if err := db.Delete(&att).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"})
return
}
// Reference count: any other (non-deleted) rows pointing at this file?
var count int64
db.Model(&models.Attachment{}).Where("stored_name = ?", stored).Count(&count)
if count == 0 {
os.Remove(filepath.Join(attachmentsDir(storagePath), stored)) // ignore error
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// ---------------- List ----------------
// ListAttachments returns the attachments for an article as JSON (used by the
// edit page to repopulate the list on load).
func ListAttachments(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
articleID := parseUintParam(c, "id")
var atts []models.Attachment
db.Where("article_id = ?", articleID).Order("created_at ASC").Find(&atts)
out := make([]gin.H, 0, len(atts))
for _, a := range atts {
out = append(out, gin.H{
"id": a.ID,
"filename": a.Filename,
"size": a.Size,
"category": a.Category,
"url": attachmentURL(a.StoredName),
"is_image": a.IsImage(),
})
}
c.JSON(http.StatusOK, gin.H{"attachments": out})
}
}
// ---------------- Binding (plan A) ----------------
// BindPendingAttachments attaches attachments uploaded during article creation
// (owned by session_token, article_id=0) to a newly created article. Called by
// ArticleCreate after the article row is saved.
func BindPendingAttachments(db *gorm.DB, token string, articleID uint) error {
if token == "" {
return nil
}
return db.Model(&models.Attachment{}).
Where("session_token = ? AND article_id = 0", token).
Updates(map[string]interface{}{"article_id": articleID, "session_token": ""}).Error
}
// ---------------- helpers ----------------
// parseUintForm parses a uint form field, tolerating empty/invalid input.
func parseUintForm(c *gin.Context, field string) uint {
v := strings.TrimSpace(c.PostForm(field))
if v == "" {
return 0
}
var n uint
_, _ = fmt.Sscanf(v, "%d", &n)
return n
}
// parseUintParam parses a uint route param.
func parseUintParam(c *gin.Context, name string) uint {
var n uint
_, _ = fmt.Sscanf(c.Param(name), "%d", &n)
return n
}
+22
View File
@@ -121,6 +121,17 @@ var translations = map[Lang]map[string]string{
"article_content_required": "Content is required.",
"article_back_home": "Back to Home",
"article_not_found": "Article not found.",
"article_attachments": "Attachments",
"article_upload": "Upload",
"article_att_name": "File",
"article_att_size": "Size",
"article_att_insert": "Insert",
"article_att_set_cover": "Set as Cover",
"article_att_cover_set": "Cover URL set.",
"article_att_pick": "Please choose a file.",
"article_att_uploading": "Uploading...",
"article_att_error": "Upload failed. Please try again.",
"article_att_delete_confirm": "Delete this attachment?",
// Article management
"article_list_title": "Articles",
@@ -293,6 +304,17 @@ var translations = map[Lang]map[string]string{
"article_content_required": "正文不能为空。",
"article_back_home": "返回首页",
"article_not_found": "文章不存在。",
"article_attachments": "附件",
"article_upload": "上传",
"article_att_name": "文件",
"article_att_size": "大小",
"article_att_insert": "插入正文",
"article_att_set_cover": "设为封面",
"article_att_cover_set": "已设为封面。",
"article_att_pick": "请先选择文件。",
"article_att_uploading": "上传中...",
"article_att_error": "上传失败,请重试。",
"article_att_delete_confirm": "删除该附件?",
// 文章管理
"article_list_title": "文章管理",
+9
View File
@@ -68,6 +68,15 @@ func main() {
admin.POST("/articles/:id/delete", handlers.ArticleDelete(db))
}
// Protected article attachment routes (AJAX uploads / management).
attachments := router.Group("/admin/articles")
attachments.Use(middleware.AuthRequired())
{
attachments.POST("/attachments", handlers.UploadAttachment(db, cfg.Path))
attachments.POST("/attachments/:id/delete", handlers.DeleteAttachment(db, cfg.Path))
attachments.GET("/:id/attachments", handlers.ListAttachments(db))
}
// Protected admin settings routes (platform configuration).
settings := router.Group("/admin/settings")
settings.Use(middleware.AuthRequired())
+48
View File
@@ -0,0 +1,48 @@
package models
import (
"time"
"gorm.io/gorm"
)
// Attachment represents a file attached to an article.
//
// Lifecycle (plan A — upload-then-bind):
// - On the article-create page the article does not exist yet, so ArticleID
// is 0 and the row is temporarily owned by SessionToken (a random value
// generated for the page session).
// - When the article is saved, ArticleCreate binds pending rows by
// SessionToken, setting their ArticleID and clearing the token.
// - On the edit page uploads carry the real ArticleID directly.
//
// Disk deduplication: StoredName is the SHA-256 of the file content. Before
// writing, the handler checks whether a file with that name already exists on
// disk; if so it is reused (no rewrite). Deletion uses reference counting —
// the disk file is removed only when no Attachment rows reference it.
type Attachment struct {
ID uint `gorm:"primarykey" json:"id"`
ArticleID uint `gorm:"index" json:"article_id"` // 0 while pending on the create page
SessionToken string `gorm:"size:64;index" json:"-"` // temporary ownership token for the create page
UploaderID uint `gorm:"index" json:"uploader_id"`
Filename string `gorm:"size:255" json:"filename"` // original filename
StoredName string `gorm:"size:64;index" json:"stored_name"` // SHA-256 hex, the on-disk filename
Ext string `gorm:"size:32" json:"ext"`
MIME string `gorm:"size:128" json:"mime"`
Size int64 `gorm:"default:0" json:"size"`
Category string `gorm:"size:32" json:"category"` // image/document/archive/video/other
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
}
// TableName overrides the default GORM table name.
func (Attachment) TableName() string {
return "attachments"
}
// AttachmentCategoryImage reports whether this attachment is an image (used to
// decide markdown insertion form: ![]() vs []()).
func (a *Attachment) IsImage() bool {
return a.Category == CategoryImage
}
+1 -1
View File
@@ -45,7 +45,7 @@ func InitDB(cfg *config.Config) *gorm.DB {
}
// Auto-migrate tables (idempotent).
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}); err != nil {
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}, &Attachment{}); err != nil {
log.Fatalf("Failed to auto-migrate database: %v", err)
}
+136
View File
@@ -11,6 +11,9 @@
{{end}}
<form action="{{.FormAction}}" method="post" class="space-y-6">
<!-- Hidden: attachment ownership (token on create, id on edit) -->
<input type="hidden" name="session_token" value="{{.SessionToken}}">
<!-- Title -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_title"}}</label>
@@ -50,6 +53,30 @@
placeholder="https://...">
</div>
<!-- Attachments -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_attachments"}}</label>
<div class="flex items-center gap-3 mb-3">
<input type="file" id="attachmentInput" class="text-sm text-gray-600" disabled>
<button type="button" id="attachmentUploadBtn"
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors cursor-pointer disabled:opacity-50"
disabled>
{{index .Tr "article_upload"}}
</button>
<span id="attachmentMsg" class="text-xs text-gray-400"></span>
</div>
<table class="w-full text-left border border-gray-200 rounded-lg overflow-hidden">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-3 py-2 text-xs font-semibold text-gray-600">{{index .Tr "article_att_name"}}</th>
<th class="px-3 py-2 text-xs font-semibold text-gray-600">{{index .Tr "article_att_size"}}</th>
<th class="px-3 py-2 text-xs font-semibold text-gray-600 text-right">{{index .Tr "settings_actions"}}</th>
</tr>
</thead>
<tbody id="attachmentList" class="divide-y divide-gray-100"></tbody>
</table>
</div>
<!-- IsTop -->
<div class="flex items-center gap-2">
<input type="checkbox" name="is_top" value="1" id="isTopCheckbox" {{if .FormIsTop}}checked{{end}}
@@ -89,6 +116,115 @@ var easyMDE = new EasyMDE({
status: false,
minHeight: '300px'
});
// ---- Attachments ----
(function () {
var articleID = {{ if .FormArticleID }}{{ .FormArticleID }}{{ else }}0{{ end }};
var sessionToken = "{{ .SessionToken }}";
var uploadBtn = document.getElementById('attachmentUploadBtn');
var fileInput = document.getElementById('attachmentInput');
var msgEl = document.getElementById('attachmentMsg');
var listEl = document.getElementById('attachmentList');
// Enable the uploader (uploads allowed only while logged in, which is true here).
uploadBtn.disabled = false;
fileInput.disabled = false;
function fmtSize(b) {
if (b < 1024) return b + ' B';
var u = ['KiB', 'MiB', 'GiB'], i = -1;
do { b /= 1024; i++; } while (b >= 1024 && i < u.length - 1);
return b.toFixed(1) + ' ' + u[i];
}
function addRow(att) {
var tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50';
tr.dataset.id = att.id;
var nameTd = document.createElement('td');
nameTd.className = 'px-3 py-2 text-sm text-gray-800';
var link = document.createElement('a');
link.href = att.url; link.target = '_blank'; link.textContent = att.filename;
nameTd.appendChild(link);
var sizeTd = document.createElement('td');
sizeTd.className = 'px-3 py-2 text-sm text-gray-500';
sizeTd.textContent = fmtSize(att.size);
var actTd = document.createElement('td');
actTd.className = 'px-3 py-2 text-sm text-right whitespace-nowrap';
var insBtn = document.createElement('button');
insBtn.type = 'button';
insBtn.textContent = "{{index .Tr "article_att_insert"}}";
insBtn.className = 'text-blue-600 hover:text-blue-800 font-medium mr-3 cursor-pointer bg-transparent border-none';
insBtn.onclick = function () {
var md = att.is_image
? '![' + att.filename + '](' + att.url + ')'
: '[' + att.filename + '](' + att.url + ')';
var cm = easyMDE.codemirror;
cm.replaceSelection(md + '\n');
cm.focus();
};
// Images: extra button to copy the URL into the cover field.
var coverBtn = null;
if (att.is_image) {
coverBtn = document.createElement('button');
coverBtn.type = 'button';
coverBtn.textContent = "{{index .Tr "article_att_set_cover"}}";
coverBtn.className = 'text-green-600 hover:text-green-800 font-medium mr-3 cursor-pointer bg-transparent border-none';
coverBtn.onclick = function () {
var cover = document.querySelector('input[name="cover"]');
if (cover) { cover.value = att.url; msgEl.textContent = "{{index .Tr "article_att_cover_set"}}"; }
};
}
var delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.textContent = "{{index .Tr "settings_delete"}}";
delBtn.className = 'text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none';
delBtn.onclick = function () {
if (!confirm("{{index .Tr "article_att_delete_confirm"}}")) return;
fetch('/admin/articles/attachments/' + att.id + '/delete', { method: 'POST' })
.then(function (r) { return r.json(); })
.then(function (r) {
if (r.ok) { tr.remove(); }
else { msgEl.textContent = r.error || 'error'; }
});
};
actTd.appendChild(insBtn);
if (coverBtn) { actTd.appendChild(coverBtn); }
actTd.appendChild(delBtn);
tr.appendChild(nameTd);
tr.appendChild(sizeTd);
tr.appendChild(actTd);
listEl.appendChild(tr);
}
uploadBtn.addEventListener('click', function () {
if (!fileInput.files.length) { msgEl.textContent = "{{index .Tr "article_att_pick"}}"; return; }
var fd = new FormData();
fd.append('file', fileInput.files[0]);
if (articleID) { fd.append('article_id', articleID); }
else { fd.append('session_token', sessionToken); }
msgEl.textContent = "{{index .Tr "article_att_uploading"}}";
fetch('/admin/articles/attachments', { method: 'POST', body: fd })
.then(function (r) { return r.json(); })
.then(function (r) {
if (r.error) { msgEl.textContent = r.error; return; }
msgEl.textContent = '';
addRow(r);
fileInput.value = '';
})
.catch(function () { msgEl.textContent = "{{index .Tr "article_att_error"}}"; });
});
// Edit page: load existing attachments.
if (articleID) {
fetch('/admin/articles/' + articleID + '/attachments')
.then(function (r) { return r.json(); })
.then(function (r) {
(r.attachments || []).forEach(addRow);
});
}
})();
</script>
{{end}}