diff --git a/handlers/article.go b/handlers/article.go index 81ffdae..debca07 100644 --- a/handlers/article.go +++ b/handlers/article.go @@ -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, }, "") } } diff --git a/handlers/attachment.go b/handlers/attachment.go new file mode 100644 index 0000000..f97f482 --- /dev/null +++ b/handlers/attachment.go @@ -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/" — 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 +} diff --git a/i18n/i18n.go b/i18n/i18n.go index bf04224..1bc735e 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -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": "文章管理", diff --git a/main.go b/main.go index 54af794..36cf6c5 100644 --- a/main.go +++ b/main.go @@ -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()) diff --git a/models/attachment.go b/models/attachment.go new file mode 100644 index 0000000..98d76d3 --- /dev/null +++ b/models/attachment.go @@ -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 +} diff --git a/models/db.go b/models/db.go index 25cdb30..c4bc845 100644 --- a/models/db.go +++ b/models/db.go @@ -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) } diff --git a/templates/admin/article_create.html b/templates/admin/article_create.html index 152c399..dd68a35 100644 --- a/templates/admin/article_create.html +++ b/templates/admin/article_create.html @@ -11,6 +11,9 @@ {{end}}
+ + +
@@ -50,6 +53,30 @@ placeholder="https://...">
+ +
+ +
+ + + +
+ + + + + + + + + +
{{index .Tr "article_att_name"}}{{index .Tr "article_att_size"}}{{index .Tr "settings_actions"}}
+
+
= 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); + }); + } +})(); {{end}}