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

49 lines
2.2 KiB
Go

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
}