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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user