P0(高危): - 新增全局 CSRF 中间件(同步器令牌),覆盖全部 30 个表单与 AJAX 请求 - 修复附件上传/列表/删除越权(IDOR),增加 admin/上传者/文章作者所有权校验 - 登录/注册成功后会话轮换,修复会话固定 - 会话密钥改用 crypto/rand 生成,配置缺失 secret 时拒绝启动 P1(中危): - session 与 comment_uid cookie 增加 Secure/SameSite 标志 - 新增安全响应头:CSP、X-Content-Type-Options、X-Frame-Options、HSTS 等 - 新增 web.trusted_proxies 配置,修复 X-Forwarded-For 伪造 - 修复浏览量记录 goroutine 访问已回收 gin.Context 的数据竞争 补充 17 个安全回归测试(middleware/handlers),go test -race 全绿
288 lines
8.6 KiB
Go
288 lines
8.6 KiB
Go
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 ----------------
|
|
|
|
// currentUserIsAdmin reports whether the authenticated user has the admin
|
|
// role, based on the context populated by the SetUserContext middleware.
|
|
func currentUserIsAdmin(c *gin.Context) bool {
|
|
role, _ := c.Get("role")
|
|
r, _ := role.(string)
|
|
return r == models.RoleAdmin
|
|
}
|
|
|
|
// canManageArticle reports whether the current user may attach files to (or
|
|
// manage attachments of) the given article: admins always, the article
|
|
// author otherwise.
|
|
func canManageArticle(c *gin.Context, db *gorm.DB, articleID uint) bool {
|
|
if currentUserIsAdmin(c) {
|
|
return true
|
|
}
|
|
uid, ok := sessionAuthorID(c)
|
|
if !ok || articleID == 0 {
|
|
return false
|
|
}
|
|
var article models.Article
|
|
if err := db.First(&article, "id = ?", articleID).Error; err != nil {
|
|
return false
|
|
}
|
|
return article.AuthorID == uid
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Ownership check: a non-admin may only attach to their own articles.
|
|
if articleID != 0 && !canManageArticle(c, db, articleID) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
|
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). Only admins, the uploader, or the
|
|
// author of the article the file is attached to may delete it.
|
|
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
|
|
}
|
|
|
|
// Ownership check: admin, uploader, or the owning article's author.
|
|
if !currentUserIsAdmin(c) {
|
|
uid, ok := sessionAuthorID(c)
|
|
owned := ok && att.UploaderID == uid
|
|
if !owned && att.ArticleID != 0 {
|
|
owned = canManageArticle(c, db, att.ArticleID)
|
|
}
|
|
if !owned {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
|
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). Only the article's author (or an
|
|
// admin) may list them.
|
|
func ListAttachments(db *gorm.DB) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
articleID := parseUintParam(c, "id")
|
|
if articleID == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid article id"})
|
|
return
|
|
}
|
|
if !canManageArticle(c, db, articleID) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
|
return
|
|
}
|
|
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
|
|
}
|