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 }