Files
kevinandClaude Fable 5 e872c08f3b 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>
2026-06-21 21:02:09 +08:00

233 lines
6.9 KiB
Go

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
}