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:
@@ -1,6 +1,8 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -17,6 +19,17 @@ import (
|
|||||||
// slugRe matches characters that are not URL-safe.
|
// slugRe matches characters that are not URL-safe.
|
||||||
var slugRe = regexp.MustCompile(`[^a-z0-9\-]+`)
|
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.
|
// generateSlug converts a title into a URL-friendly slug.
|
||||||
func generateSlug(title string) string {
|
func generateSlug(title string) string {
|
||||||
s := strings.ToLower(title)
|
s := strings.ToLower(title)
|
||||||
@@ -40,6 +53,8 @@ type articleForm struct {
|
|||||||
IsTop bool
|
IsTop bool
|
||||||
Action string // form action URL
|
Action string // form action URL
|
||||||
TitleText string // page heading text (create vs edit)
|
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.
|
// 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["FormIsTop"] = f.IsTop
|
||||||
data["FormAction"] = f.Action
|
data["FormAction"] = f.Action
|
||||||
data["FormTitleText"] = f.TitleText
|
data["FormTitleText"] = f.TitleText
|
||||||
|
data["FormArticleID"] = f.ArticleID
|
||||||
|
data["SessionToken"] = f.SessionToken
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderArticleForm renders the shared article form template with the given
|
// renderArticleForm renders the shared article form template with the given
|
||||||
@@ -120,6 +137,7 @@ func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc {
|
|||||||
Action: "/admin/articles/new",
|
Action: "/admin/articles/new",
|
||||||
TitleText: tr["article_create_title"],
|
TitleText: tr["article_create_title"],
|
||||||
StatusStr: "0",
|
StatusStr: "0",
|
||||||
|
SessionToken: randomToken(),
|
||||||
}, "")
|
}, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,6 +149,7 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc {
|
|||||||
f := parseArticleForm(c)
|
f := parseArticleForm(c)
|
||||||
f.Action = "/admin/articles/new"
|
f.Action = "/admin/articles/new"
|
||||||
f.TitleText = tr["article_create_title"]
|
f.TitleText = tr["article_create_title"]
|
||||||
|
f.SessionToken = strings.TrimSpace(c.PostForm("session_token"))
|
||||||
|
|
||||||
// Validate required fields.
|
// Validate required fields.
|
||||||
if f.Title == "" {
|
if f.Title == "" {
|
||||||
@@ -178,6 +197,12 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return
|
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.
|
// Success: redirect to dashboard.
|
||||||
c.Redirect(http.StatusFound, "/admin")
|
c.Redirect(http.StatusFound, "/admin")
|
||||||
}
|
}
|
||||||
@@ -218,6 +243,7 @@ func ArticleEditPage(db *gorm.DB) gin.HandlerFunc {
|
|||||||
IsTop: article.IsTop,
|
IsTop: article.IsTop,
|
||||||
Action: "/admin/articles/" + id + "/edit",
|
Action: "/admin/articles/" + id + "/edit",
|
||||||
TitleText: tr["article_edit_title"],
|
TitleText: tr["article_edit_title"],
|
||||||
|
ArticleID: article.ID,
|
||||||
}, "")
|
}, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -121,6 +121,17 @@ var translations = map[Lang]map[string]string{
|
|||||||
"article_content_required": "Content is required.",
|
"article_content_required": "Content is required.",
|
||||||
"article_back_home": "Back to Home",
|
"article_back_home": "Back to Home",
|
||||||
"article_not_found": "Article not found.",
|
"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 management
|
||||||
"article_list_title": "Articles",
|
"article_list_title": "Articles",
|
||||||
@@ -293,6 +304,17 @@ var translations = map[Lang]map[string]string{
|
|||||||
"article_content_required": "正文不能为空。",
|
"article_content_required": "正文不能为空。",
|
||||||
"article_back_home": "返回首页",
|
"article_back_home": "返回首页",
|
||||||
"article_not_found": "文章不存在。",
|
"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": "文章管理",
|
"article_list_title": "文章管理",
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ func main() {
|
|||||||
admin.POST("/articles/:id/delete", handlers.ArticleDelete(db))
|
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).
|
// Protected admin settings routes (platform configuration).
|
||||||
settings := router.Group("/admin/settings")
|
settings := router.Group("/admin/settings")
|
||||||
settings.Use(middleware.AuthRequired())
|
settings.Use(middleware.AuthRequired())
|
||||||
|
|||||||
@@ -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
@@ -45,7 +45,7 @@ func InitDB(cfg *config.Config) *gorm.DB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-migrate tables (idempotent).
|
// 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)
|
log.Fatalf("Failed to auto-migrate database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form action="{{.FormAction}}" method="post" class="space-y-6">
|
<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 -->
|
<!-- Title -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_title"}}</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_title"}}</label>
|
||||||
@@ -50,6 +53,30 @@
|
|||||||
placeholder="https://...">
|
placeholder="https://...">
|
||||||
</div>
|
</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 -->
|
<!-- IsTop -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<input type="checkbox" name="is_top" value="1" id="isTopCheckbox" {{if .FormIsTop}}checked{{end}}
|
<input type="checkbox" name="is_top" value="1" id="isTopCheckbox" {{if .FormIsTop}}checked{{end}}
|
||||||
@@ -89,6 +116,115 @@ var easyMDE = new EasyMDE({
|
|||||||
status: false,
|
status: false,
|
||||||
minHeight: '300px'
|
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 + ')';
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user