feat(attach): 附件可配置限额、流式落盘与分阶段上传
管理端可配类型/体积;上传改流式避免整文件进内存;发帖先选再传并拦截未上传提交;提高 Next proxy 体积上限。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,7 +2,6 @@ package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
@@ -282,11 +281,46 @@ func (h *Handlers) AdminPointsStats(c *gin.Context) {
|
||||
// UploadPostFile 上传帖子附件(草稿态)
|
||||
func (h *Handlers) UploadPostFile(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
|
||||
maxBytes, err := h.Setting.AttachmentMaxBytes()
|
||||
if err != nil || maxBytes < 1 {
|
||||
maxBytes = service.FileMaxBytes
|
||||
}
|
||||
overhead := int64(64 << 10) // multipart 边界开销
|
||||
limit := maxBytes + overhead
|
||||
|
||||
if c.Request.ContentLength > limit {
|
||||
mb := int(maxBytes >> 20)
|
||||
if mb < 1 {
|
||||
mb = 1
|
||||
}
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "附件不能超过 " + strconv.Itoa(mb) + "MB"})
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
mb := int(maxBytes >> 20)
|
||||
if mb < 1 {
|
||||
mb = 1
|
||||
}
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "附件不能超过 " + strconv.Itoa(mb) + "MB"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择文件"})
|
||||
return
|
||||
}
|
||||
if file.Size > maxBytes {
|
||||
mb := int(maxBytes >> 20)
|
||||
if mb < 1 {
|
||||
mb = 1
|
||||
}
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "附件不能超过 " + strconv.Itoa(mb) + "MB"})
|
||||
return
|
||||
}
|
||||
price, _ := strconv.Atoi(c.DefaultPostForm("price_points", "0"))
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
@@ -294,13 +328,18 @@ func (h *Handlers) UploadPostFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(f, service.FileMaxBytes+1))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "读取文件失败"})
|
||||
return
|
||||
}
|
||||
att, err := h.PostFile.SaveDraftFile(claims.ID, file.Filename, data, price)
|
||||
|
||||
att, err := h.PostFile.SaveDraftFile(claims.ID, file.Filename, f, price)
|
||||
if err != nil {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) || errors.Is(err, service.ErrAttachmentTooLarge) {
|
||||
mb := int(maxBytes >> 20)
|
||||
if mb < 1 {
|
||||
mb = 1
|
||||
}
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "附件不能超过 " + strconv.Itoa(mb) + "MB"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -399,7 +438,14 @@ func (h *Handlers) DownloadPostAttachment(c *gin.Context) {
|
||||
path := h.PostFile.FilePath(att)
|
||||
h.PostFile.IncDownload(att.ID)
|
||||
c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(att.Name))
|
||||
c.Header("Content-Type", att.MIME)
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("Content-Security-Policy", "sandbox")
|
||||
ct := att.MIME
|
||||
ext := service.ExtOfFilename(att.Name)
|
||||
if service.IsActiveContentExt(ext) || ct == "" {
|
||||
ct = "application/octet-stream"
|
||||
}
|
||||
c.Header("Content-Type", ct)
|
||||
c.File(path)
|
||||
_ = filepath.Base(path)
|
||||
}
|
||||
|
||||
@@ -21,21 +21,26 @@ func (h *Handlers) PublicSettings(c *gin.Context) {
|
||||
|
||||
// updateSettingsRequest 字段均为可选指针:只更新请求里出现的项,避免外观页覆盖其它设置
|
||||
type updateSettingsRequest struct {
|
||||
Accent *string `json:"accent"`
|
||||
TrustReviewedPublish *bool `json:"trust_reviewed_publish"`
|
||||
SiteName *string `json:"site_name"`
|
||||
SiteDescription *string `json:"site_description"`
|
||||
AllowRegister *bool `json:"allow_register"`
|
||||
AllowComments *bool `json:"allow_comments"`
|
||||
AllowMessages *bool `json:"allow_messages"`
|
||||
PostCooldownHours *int `json:"post_cooldown_hours"`
|
||||
CodeBlockAutoFold *bool `json:"code_block_auto_fold"`
|
||||
CodeBlockFoldLines *int `json:"code_block_fold_lines"`
|
||||
UIAnimations *bool `json:"ui_animations"`
|
||||
AnimCodeFold *bool `json:"anim_code_fold"`
|
||||
AnimSmoothScroll *bool `json:"anim_smooth_scroll"`
|
||||
AnimChrome *bool `json:"anim_chrome"`
|
||||
PostLinkNewTab *bool `json:"post_link_new_tab"`
|
||||
Accent *string `json:"accent"`
|
||||
TrustReviewedPublish *bool `json:"trust_reviewed_publish"`
|
||||
SiteName *string `json:"site_name"`
|
||||
SiteDescription *string `json:"site_description"`
|
||||
AllowRegister *bool `json:"allow_register"`
|
||||
AllowComments *bool `json:"allow_comments"`
|
||||
AllowMessages *bool `json:"allow_messages"`
|
||||
PostCooldownHours *int `json:"post_cooldown_hours"`
|
||||
CodeBlockAutoFold *bool `json:"code_block_auto_fold"`
|
||||
CodeBlockFoldLines *int `json:"code_block_fold_lines"`
|
||||
UIAnimations *bool `json:"ui_animations"`
|
||||
AnimCodeFold *bool `json:"anim_code_fold"`
|
||||
AnimSmoothScroll *bool `json:"anim_smooth_scroll"`
|
||||
AnimChrome *bool `json:"anim_chrome"`
|
||||
PostLinkNewTab *bool `json:"post_link_new_tab"`
|
||||
AttachmentExtLimit *bool `json:"attachment_ext_limit"`
|
||||
AttachmentExts *[]string `json:"attachment_exts"`
|
||||
AttachmentMaxMB *int `json:"attachment_max_mb"`
|
||||
AttachmentMaxCount *int `json:"attachment_max_count"`
|
||||
ImageMaxMB *int `json:"image_max_mb"`
|
||||
}
|
||||
|
||||
func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||
@@ -55,9 +60,24 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||
"anim_smooth_scroll": saved.AnimSmoothScroll,
|
||||
"anim_chrome": saved.AnimChrome,
|
||||
"post_link_new_tab": saved.PostLinkNewTab,
|
||||
"attachment_ext_limit": saved.AttachmentExtLimit,
|
||||
"attachment_exts": saved.AttachmentExts,
|
||||
"attachment_max_mb": saved.AttachmentMaxMB,
|
||||
"attachment_max_count": saved.AttachmentMaxCount,
|
||||
"image_max_mb": saved.ImageMaxMB,
|
||||
}
|
||||
}
|
||||
|
||||
func (req *updateSettingsRequest) hasAny() bool {
|
||||
return req.Accent != nil || req.TrustReviewedPublish != nil || req.SiteName != nil ||
|
||||
req.SiteDescription != nil || req.AllowRegister != nil || req.AllowComments != nil ||
|
||||
req.AllowMessages != nil || req.PostCooldownHours != nil || req.CodeBlockAutoFold != nil ||
|
||||
req.CodeBlockFoldLines != nil || req.UIAnimations != nil || req.AnimCodeFold != nil ||
|
||||
req.AnimSmoothScroll != nil || req.AnimChrome != nil || req.PostLinkNewTab != nil ||
|
||||
req.AttachmentExtLimit != nil || req.AttachmentExts != nil || req.AttachmentMaxMB != nil ||
|
||||
req.AttachmentMaxCount != nil || req.ImageMaxMB != nil
|
||||
}
|
||||
|
||||
// PUT /api/admin/settings
|
||||
func (h *Handlers) UpdateSettings(c *gin.Context) {
|
||||
var req updateSettingsRequest
|
||||
@@ -65,12 +85,7 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
if req.Accent == nil && req.TrustReviewedPublish == nil && req.SiteName == nil &&
|
||||
req.SiteDescription == nil && req.AllowRegister == nil && req.AllowComments == nil &&
|
||||
req.AllowMessages == nil &&
|
||||
req.PostCooldownHours == nil && req.CodeBlockAutoFold == nil && req.CodeBlockFoldLines == nil &&
|
||||
req.UIAnimations == nil && req.AnimCodeFold == nil && req.AnimSmoothScroll == nil &&
|
||||
req.AnimChrome == nil && req.PostLinkNewTab == nil {
|
||||
if !req.hasAny() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "未提供任何可更新的设置项"})
|
||||
return
|
||||
}
|
||||
@@ -185,6 +200,52 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.AttachmentExtLimit != nil {
|
||||
if err := h.Setting.SetAttachmentExtLimit(*req.AttachmentExtLimit); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.AttachmentExts != nil {
|
||||
if err := h.Setting.SetAttachmentExts(*req.AttachmentExts); err != nil {
|
||||
if errors.Is(err, service.ErrInvalidSiteSetting) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "扩展名无效或数量超限(最多 80 个,仅字母数字)"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.AttachmentMaxMB != nil {
|
||||
if err := h.Setting.SetAttachmentMaxMB(*req.AttachmentMaxMB); err != nil {
|
||||
if errors.Is(err, service.ErrInvalidSiteSetting) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "附件上限须至少 1MB"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.AttachmentMaxCount != nil {
|
||||
if err := h.Setting.SetAttachmentMaxCount(*req.AttachmentMaxCount); err != nil {
|
||||
if errors.Is(err, service.ErrInvalidSiteSetting) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "每帖附件数须为 1–20"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.ImageMaxMB != nil {
|
||||
if err := h.Setting.SetImageMaxMB(*req.ImageMaxMB); err != nil {
|
||||
if errors.Is(err, service.ErrInvalidSiteSetting) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "插图上限须至少 1MB"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
saved, err := h.Setting.Public()
|
||||
if err != nil {
|
||||
|
||||
@@ -53,15 +53,43 @@ func (h *Handlers) UploadAvatar(c *gin.Context) {
|
||||
func (h *Handlers) UploadImage(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, service.ImageMaxBytes+4096)
|
||||
maxBytes, err := h.Setting.ImageMaxBytes()
|
||||
if err != nil || maxBytes < 1 {
|
||||
maxBytes = service.ImageMaxBytes
|
||||
}
|
||||
overhead := int64(4096)
|
||||
limit := maxBytes + overhead
|
||||
|
||||
if c.Request.ContentLength > limit {
|
||||
mb := int(maxBytes >> 20)
|
||||
if mb < 1 {
|
||||
mb = 1
|
||||
}
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "图片不能超过 " + strconv.Itoa(mb) + "MB"})
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
|
||||
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "图片过大(不能超过 5MB)或格式不正确"})
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
mb := int(maxBytes >> 20)
|
||||
if mb < 1 {
|
||||
mb = 1
|
||||
}
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "图片不能超过 " + strconv.Itoa(mb) + "MB"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "图片过大或格式不正确"})
|
||||
return
|
||||
}
|
||||
if fh.Size > service.ImageMaxBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 5MB"})
|
||||
if fh.Size > maxBytes {
|
||||
mb := int(maxBytes >> 20)
|
||||
if mb < 1 {
|
||||
mb = 1
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 " + strconv.Itoa(mb) + "MB"})
|
||||
return
|
||||
}
|
||||
f, err := fh.Open()
|
||||
@@ -71,18 +99,17 @@ func (h *Handlers) UploadImage(c *gin.Context) {
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(f, service.ImageMaxBytes+1))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"})
|
||||
return
|
||||
}
|
||||
if len(data) > service.ImageMaxBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 5MB"})
|
||||
return
|
||||
}
|
||||
|
||||
att, err := h.Upload.SaveImage(claims.ID, data)
|
||||
att, err := h.Upload.SaveImage(claims.ID, f)
|
||||
if err != nil {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
mb := int(maxBytes >> 20)
|
||||
if mb < 1 {
|
||||
mb = 1
|
||||
}
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "图片不能超过 " + strconv.Itoa(mb) + "MB"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user