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
|
||||
}
|
||||
|
||||
@@ -398,7 +398,7 @@ type Announcement struct {
|
||||
// SiteSetting 站点级键值设置(如历史在线峰值 peak_online)
|
||||
type SiteSetting struct {
|
||||
Key string `gorm:"primaryKey;size:64" json:"key"`
|
||||
Value string `gorm:"size:255;not null;default:''" json:"value"`
|
||||
Value string `gorm:"type:text;not null;default:''" json:"value"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
// 大附件落盘到临时文件,避免 multipart 整文件进内存(默认 32MiB)
|
||||
r.MaxMultipartMemory = 4 << 20
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(gin.Logger())
|
||||
|
||||
@@ -49,8 +51,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
overviewSvc := service.NewOverviewService(model.DB)
|
||||
checkinSvc := service.NewCheckinService(model.DB)
|
||||
announcementSvc := service.NewAnnouncementService(model.DB)
|
||||
uploadSvc := service.NewUploadService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
|
||||
postFileSvc := service.NewPostFileService(model.DB, filepath.Join(cfg.DataDir, "private"))
|
||||
uploadSvc := service.NewUploadService(model.DB, filepath.Join(cfg.DataDir, "uploads")).WithSetting(settingSvc)
|
||||
postFileSvc := service.NewPostFileService(model.DB, filepath.Join(cfg.DataDir, "private")).WithSetting(settingSvc)
|
||||
pointsSvc := service.NewPointsService(model.DB)
|
||||
adminUserSvc := service.NewAdminUserService(model.DB)
|
||||
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
||||
|
||||
36
backend/service/attachment_settings_test.go
Normal file
36
backend/service/attachment_settings_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAttachmentExtAllowedLogic(t *testing.T) {
|
||||
// Normalize + default list contains msi/html
|
||||
found := false
|
||||
for _, e := range DefaultAttachmentExts {
|
||||
if e == "msi" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("default list should include msi")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMimeForAttachmentExt(t *testing.T) {
|
||||
if MimeForAttachmentExt(".pdf") == "application/octet-stream" {
|
||||
t.Fatal("pdf should have real mime")
|
||||
}
|
||||
if MimeForAttachmentExt(".html") != "application/octet-stream" {
|
||||
t.Fatal("html should be octet-stream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileMaxBytesDefault(t *testing.T) {
|
||||
if FileMaxBytes != 20<<20 {
|
||||
t.Fatalf("default FileMaxBytes=%d", FileMaxBytes)
|
||||
}
|
||||
_ = fmt.Sprintf("%d", DefaultAttachmentMaxMB)
|
||||
}
|
||||
176
backend/service/fileallow.go
Normal file
176
backend/service/fileallow.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// 活跃内容扩展名:下载时强制 application/octet-stream,避免浏览器当页面执行
|
||||
var activeContentExts = map[string]struct{}{
|
||||
".html": {}, ".htm": {}, ".xhtml": {},
|
||||
".svg": {}, ".xml": {},
|
||||
".js": {}, ".mjs": {}, ".css": {},
|
||||
".wasm": {},
|
||||
}
|
||||
|
||||
// RasterImageExts 栅格图扩展名(可 DecodeConfig;伪装内容降为 octet-stream)
|
||||
var RasterImageExts = map[string]struct{}{
|
||||
".jpg": {}, ".jpeg": {}, ".png": {}, ".webp": {}, ".gif": {},
|
||||
}
|
||||
|
||||
const attachmentImageMaxDim = 4096
|
||||
|
||||
// IsActiveContentExt 是否为浏览器可能内联执行的扩展名
|
||||
func IsActiveContentExt(ext string) bool {
|
||||
_, ok := activeContentExts[strings.ToLower(ext)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// MimeForAttachmentExt 按扩展名映射 MIME;未知或活跃内容走 octet-stream 由调用方再处理
|
||||
func MimeForAttachmentExt(ext string) string {
|
||||
ext = strings.ToLower(ext)
|
||||
if IsActiveContentExt(ext) {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
if t := mime.TypeByExtension(ext); t != "" {
|
||||
if i := strings.IndexByte(t, ';'); i >= 0 {
|
||||
t = strings.TrimSpace(t[:i])
|
||||
}
|
||||
return t
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// looksLikeMarkupOrScript 检测内容是否像 HTML/SVG/XML 伪装(前 512 字节)
|
||||
func looksLikeMarkupOrScript(data []byte) bool {
|
||||
n := len(data)
|
||||
if n > 512 {
|
||||
n = 512
|
||||
}
|
||||
head := bytes.TrimLeft(data[:n], " \t\r\n")
|
||||
if len(head) == 0 {
|
||||
return false
|
||||
}
|
||||
lower := bytes.ToLower(head)
|
||||
prefixes := [][]byte{
|
||||
[]byte("<!doctype"),
|
||||
[]byte("<html"),
|
||||
[]byte("<svg"),
|
||||
[]byte("<?xml"),
|
||||
[]byte("<script"),
|
||||
}
|
||||
for _, p := range prefixes {
|
||||
if bytes.HasPrefix(lower, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ResolveAttachmentMIME 根据扩展名与内存内容决定 MIME(小样例/测试用)。
|
||||
func ResolveAttachmentMIME(ext string, data []byte) string {
|
||||
ext = strings.ToLower(ext)
|
||||
if IsActiveContentExt(ext) {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
if _, ok := RasterImageExts[ext]; ok {
|
||||
if looksLikeMarkupOrScript(data) {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
if !validRasterImage(bytes.NewReader(data), ext) {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
return MimeForAttachmentExt(ext)
|
||||
}
|
||||
return MimeForAttachmentExt(ext)
|
||||
}
|
||||
|
||||
// ResolveAttachmentMIMEFile 只读文件头判定 MIME,不把整文件读进内存。
|
||||
func ResolveAttachmentMIMEFile(ext, path string) string {
|
||||
ext = strings.ToLower(ext)
|
||||
if IsActiveContentExt(ext) {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, ok := RasterImageExts[ext]; ok {
|
||||
head := make([]byte, 512)
|
||||
n, _ := io.ReadFull(f, head)
|
||||
if n > 0 && looksLikeMarkupOrScript(head[:n]) {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
if !validRasterImage(f, ext) {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
return MimeForAttachmentExt(ext)
|
||||
}
|
||||
return MimeForAttachmentExt(ext)
|
||||
}
|
||||
|
||||
func validRasterImage(r io.Reader, ext string) bool {
|
||||
var cfg image.Config
|
||||
var err error
|
||||
switch ext {
|
||||
case ".webp":
|
||||
cfg, err = webp.DecodeConfig(r)
|
||||
default:
|
||||
cfg, _, err = image.DecodeConfig(r)
|
||||
}
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if cfg.Width < 1 || cfg.Height < 1 {
|
||||
return false
|
||||
}
|
||||
if cfg.Width > attachmentImageMaxDim || cfg.Height > attachmentImageMaxDim {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// sanitizeFilename 清理上传文件名:去路径、危险字符、RTL 伪装、截断
|
||||
func sanitizeFilename(name string) string {
|
||||
name = filepath.Base(strings.ReplaceAll(name, "\\", "/"))
|
||||
name = strings.TrimSpace(name)
|
||||
name = strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '/', '\\', '\x00', ':', '*', '?', '"', '<', '>', '|',
|
||||
'\u202e', '\u202d', '\u200f', '\u200e',
|
||||
'\ufeff':
|
||||
return '_'
|
||||
default:
|
||||
if r < 0x20 {
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}
|
||||
}, name)
|
||||
if utf8.RuneCountInString(name) > 200 {
|
||||
runes := []rune(name)
|
||||
name = string(runes[:200])
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// ExtOfFilename 取小写扩展名(含点),无则空串
|
||||
func ExtOfFilename(name string) string {
|
||||
return strings.ToLower(filepath.Ext(name))
|
||||
}
|
||||
70
backend/service/fileallow_test.go
Normal file
70
backend/service/fileallow_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeAttachmentExts(t *testing.T) {
|
||||
got, err := NormalizeAttachmentExts([]string{".PDF", "msi", "PDF", " zip "})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"pdf", "msi", "zip"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len=%d want %d: %v", len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("got[%d]=%s want %s", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAttachmentExtsInvalid(t *testing.T) {
|
||||
_, err := NormalizeAttachmentExts([]string{"bad_ext"})
|
||||
if err != ErrInvalidSiteSetting {
|
||||
t.Fatalf("want ErrInvalidSiteSetting, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAttachmentMIMEActive(t *testing.T) {
|
||||
m := ResolveAttachmentMIME(".html", []byte("<!DOCTYPE html><html></html>"))
|
||||
if m != "application/octet-stream" {
|
||||
t.Fatalf("html mime=%s", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAttachmentMIMEDisguisedPNG(t *testing.T) {
|
||||
m := ResolveAttachmentMIME(".png", []byte("<svg xmlns='http://www.w3.org/2000/svg'></svg>"))
|
||||
if m != "application/octet-stream" {
|
||||
t.Fatalf("disguised png mime=%s", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsActiveContentExt(t *testing.T) {
|
||||
if !IsActiveContentExt(".JS") {
|
||||
t.Fatal("js should be active")
|
||||
}
|
||||
if IsActiveContentExt(".pdf") {
|
||||
t.Fatal("pdf should not be active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFilenameRTL(t *testing.T) {
|
||||
name := sanitizeFilename("evil\u202epdf.exe")
|
||||
if name != "evil_pdf.exe" && name != "evil_.exe" {
|
||||
// Map replaces U+202E with _
|
||||
if !containsRune(name, '_') {
|
||||
t.Fatalf("expected RTL stripped: %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsRune(s string, r rune) bool {
|
||||
for _, c := range s {
|
||||
if c == r {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -4,37 +4,48 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"mime"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 兼容旧引用:默认体积/个数(实际限额读 SettingService)
|
||||
const (
|
||||
FileMaxBytes = 20 << 20 // 20 MiB
|
||||
FileMaxBytes = 20 << 20 // 默认 20 MiB(缺省配置)
|
||||
MaxPostAttachments = 10
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAttachmentNotFound = errors.New("附件不存在")
|
||||
ErrAttachmentNotFound = errors.New("附件不存在")
|
||||
ErrAttachmentForbidden = errors.New("无权操作此附件")
|
||||
ErrTooManyAttachments = errors.New("附件数量超过上限")
|
||||
ErrTooManyAttachments = errors.New("附件数量超过上限")
|
||||
ErrAttachmentExtDenied = errors.New("不支持该附件格式")
|
||||
ErrAttachmentTooLarge = errors.New("附件过大")
|
||||
ErrAttachmentEmpty = errors.New("文件为空")
|
||||
)
|
||||
|
||||
// PostFileService 帖子文件附件(私有目录 + API 下载)
|
||||
type PostFileService struct {
|
||||
db *gorm.DB
|
||||
dir string // data/private/files
|
||||
db *gorm.DB
|
||||
dir string // data/private/files
|
||||
setting *SettingService
|
||||
}
|
||||
|
||||
func NewPostFileService(db *gorm.DB, privateDir string) *PostFileService {
|
||||
return &PostFileService{db: db, dir: filepath.Join(privateDir, "files")}
|
||||
}
|
||||
|
||||
// WithSetting 注入站点设置(附件限额/扩展名)
|
||||
func (s *PostFileService) WithSetting(setting *SettingService) *PostFileService {
|
||||
s.setting = setting
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *PostFileService) EnsureDir() error {
|
||||
return os.MkdirAll(s.dir, 0o755)
|
||||
}
|
||||
@@ -43,13 +54,40 @@ func absPath(dir, stored string) string {
|
||||
return filepath.Join(dir, stored)
|
||||
}
|
||||
|
||||
// SaveDraftFile 上传附件(先挂 post_id=0,发帖时绑定)
|
||||
func (s *PostFileService) SaveDraftFile(userID uint, originalName string, data []byte, pricePoints int) (*model.PostAttachment, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("文件为空")
|
||||
func (s *PostFileService) maxBytes() int64 {
|
||||
if s.setting == nil {
|
||||
return FileMaxBytes
|
||||
}
|
||||
if len(data) > FileMaxBytes {
|
||||
return nil, errors.New("附件不能超过 20MB")
|
||||
n, err := s.setting.AttachmentMaxBytes()
|
||||
if err != nil || n < 1 {
|
||||
return FileMaxBytes
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *PostFileService) maxCount() int {
|
||||
if s.setting == nil {
|
||||
return MaxPostAttachments
|
||||
}
|
||||
n, err := s.setting.AttachmentMaxCount()
|
||||
if err != nil || n < 1 {
|
||||
return MaxPostAttachments
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *PostFileService) tooLargeErr() error {
|
||||
mb := int(s.maxBytes() >> 20)
|
||||
if mb < 1 {
|
||||
mb = 1
|
||||
}
|
||||
return fmt.Errorf("%w:不能超过 %dMB", ErrAttachmentTooLarge, mb)
|
||||
}
|
||||
|
||||
// SaveDraftFile 流式落盘附件(先挂 post_id=0,发帖时绑定)。整文件不进内存。
|
||||
func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io.Reader, pricePoints int) (*model.PostAttachment, error) {
|
||||
if src == nil {
|
||||
return nil, ErrAttachmentEmpty
|
||||
}
|
||||
if pricePoints < 0 {
|
||||
pricePoints = 0
|
||||
@@ -62,32 +100,63 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, data [
|
||||
name = "file"
|
||||
}
|
||||
|
||||
ext := ExtOfFilename(name)
|
||||
if utf8.RuneCountInString(ext) > 16 {
|
||||
ext = ""
|
||||
}
|
||||
if s.setting != nil {
|
||||
ok, err := s.setting.AttachmentExtAllowed(ext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, ErrAttachmentExtDenied
|
||||
}
|
||||
}
|
||||
|
||||
maxC := s.maxCount()
|
||||
var orphan int64
|
||||
if err := s.db.Model(&model.PostAttachment{}).
|
||||
Where("user_id = ? AND post_id = 0", userID).Count(&orphan).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orphan >= MaxPostAttachments {
|
||||
if orphan >= int64(maxC) {
|
||||
return nil, ErrTooManyAttachments
|
||||
}
|
||||
|
||||
ext := filepath.Ext(name)
|
||||
if utf8.RuneCountInString(ext) > 16 {
|
||||
ext = ""
|
||||
}
|
||||
maxB := s.maxBytes()
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored := hex.EncodeToString(raw) + ext
|
||||
full := absPath(s.dir, stored)
|
||||
if err := os.WriteFile(full, data, 0o600); err != nil {
|
||||
tmp := full + ".partial"
|
||||
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// LimitReader 多读 1 字节以区分「刚好上限」与「超限」
|
||||
written, copyErr := io.Copy(f, io.LimitReader(src, maxB+1))
|
||||
_ = f.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, copyErr
|
||||
}
|
||||
if written == 0 {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, ErrAttachmentEmpty
|
||||
}
|
||||
if written > maxB {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, s.tooLargeErr()
|
||||
}
|
||||
|
||||
mimeType := mime.TypeByExtension(ext)
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
mimeType := ResolveAttachmentMIMEFile(ext, tmp)
|
||||
if err := os.Rename(tmp, full); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
att := &model.PostAttachment{
|
||||
@@ -96,7 +165,7 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, data [
|
||||
Name: name,
|
||||
StoredName: stored,
|
||||
MIME: mimeType,
|
||||
Size: len(data),
|
||||
Size: int(written),
|
||||
PricePoints: pricePoints,
|
||||
}
|
||||
if err := s.db.Create(att).Error; err != nil {
|
||||
@@ -106,30 +175,13 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, data [
|
||||
return att, nil
|
||||
}
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
name = filepath.Base(strings.ReplaceAll(name, "\\", "/"))
|
||||
name = strings.TrimSpace(name)
|
||||
name = strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '/', '\\', '\x00', ':', '*', '?', '"', '<', '>', '|':
|
||||
return '_'
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}, name)
|
||||
if utf8.RuneCountInString(name) > 200 {
|
||||
runes := []rune(name)
|
||||
name = string(runes[:200])
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// BindToPost 将草稿附件绑定到帖子(仅本人、未绑定)
|
||||
func (s *PostFileService) BindToPost(userID, postID uint, ids []uint) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(ids) > MaxPostAttachments {
|
||||
maxC := s.maxCount()
|
||||
if len(ids) > maxC {
|
||||
return ErrTooManyAttachments
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
@@ -148,7 +200,8 @@ func (s *PostFileService) BindToPost(userID, postID uint, ids []uint) error {
|
||||
|
||||
// ReplacePostAttachments 编辑时重绑附件列表(ids 为最终列表;可含已绑定本帖的)
|
||||
func (s *PostFileService) ReplacePostAttachments(userID, postID uint, ids []uint) error {
|
||||
if len(ids) > MaxPostAttachments {
|
||||
maxC := s.maxCount()
|
||||
if len(ids) > maxC {
|
||||
return ErrTooManyAttachments
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
@@ -164,7 +217,6 @@ func (s *PostFileService) ReplacePostAttachments(userID, postID uint, ids []uint
|
||||
return errors.New("部分附件无效或无权使用")
|
||||
}
|
||||
}
|
||||
// 解绑本帖旧附件(软删物理文件可选:P1 仅解绑)
|
||||
if err := tx.Model(&model.PostAttachment{}).
|
||||
Where("post_id = ? AND user_id = ?", postID, userID).
|
||||
Update("post_id", 0).Error; err != nil {
|
||||
@@ -220,7 +272,7 @@ func (s *PostFileService) DeleteOwn(userID, attID uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenForDownload 鉴权后打开文件;需先确认帖子可见与积分
|
||||
// Get 按 ID 取附件
|
||||
func (s *PostFileService) Get(attID uint) (*model.PostAttachment, error) {
|
||||
var att model.PostAttachment
|
||||
if err := s.db.First(&att, attID).Error; err != nil {
|
||||
@@ -268,7 +320,6 @@ func (s *PostFileService) EnsureAttachmentUnlocked(userID uint, att *model.PostA
|
||||
if _, err := DebitTx(tx, userID, att.PricePoints, model.PointReasonDownloadFile, "attachment", att.ID, "下载附件:"+att.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
// 积分转给作者
|
||||
if att.UserID > 0 && att.UserID != userID {
|
||||
if _, err := CreditTx(tx, att.UserID, att.PricePoints, model.PointReasonDownloadFile, "attachment_earn", att.ID, "附件收益:"+att.Name); err != nil {
|
||||
return err
|
||||
|
||||
@@ -39,6 +39,16 @@ const (
|
||||
SettingKeyAnimChrome = "anim_chrome"
|
||||
// SettingKeyPostLinkNewTab 帖子/评论 Markdown 外链是否新标签打开;缺行视为开启(聊天链接始终新开,不受此键控制)
|
||||
SettingKeyPostLinkNewTab = "post_link_new_tab"
|
||||
// SettingKeyAttachmentExtLimit 是否限制附件扩展名;缺行视为开启
|
||||
SettingKeyAttachmentExtLimit = "attachment_ext_limit"
|
||||
// SettingKeyAttachmentExts 允许的扩展名(逗号分隔,无点);缺行=默认列表
|
||||
SettingKeyAttachmentExts = "attachment_exts"
|
||||
// SettingKeyAttachmentMaxMB 附件单文件上限(MB);缺行=20
|
||||
SettingKeyAttachmentMaxMB = "attachment_max_mb"
|
||||
// SettingKeyAttachmentMaxCount 每帖附件个数上限;缺行=10
|
||||
SettingKeyAttachmentMaxCount = "attachment_max_count"
|
||||
// SettingKeyImageMaxMB 正文插图上限(MB);缺行=5
|
||||
SettingKeyImageMaxMB = "image_max_mb"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -51,8 +61,30 @@ const (
|
||||
MaxCooldownHours = 168
|
||||
MinCodeFoldLines = 0
|
||||
MaxCodeFoldLines = 100
|
||||
|
||||
DefaultAttachmentMaxMB = 20
|
||||
DefaultAttachmentMaxCount = 10
|
||||
DefaultImageMaxMB = 5
|
||||
MinAttachmentMaxMB = 1
|
||||
MinImageMaxMB = 1
|
||||
MinAttachmentMaxCount = 1
|
||||
MaxAttachmentMaxCount = 20
|
||||
MaxAttachmentExtCount = 80
|
||||
)
|
||||
|
||||
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
|
||||
var DefaultAttachmentExts = []string{
|
||||
"pdf", "txt", "md", "csv", "json",
|
||||
"doc", "docx", "xls", "xlsx", "ppt", "pptx",
|
||||
"zip", "7z", "rar", "tar", "gz", "tgz",
|
||||
"jpg", "jpeg", "png", "webp", "gif",
|
||||
"mp3", "wav", "ogg", "mp4", "webm",
|
||||
"exe", "msi", "msp", "apk", "dmg", "iso", "deb", "rpm", "dll",
|
||||
"html", "htm", "js", "mjs", "css", "svg", "xml", "bat", "cmd", "ps1", "sh",
|
||||
}
|
||||
|
||||
var attachmentExtRe = regexp.MustCompile(`^[a-z0-9]{1,16}$`)
|
||||
|
||||
// ErrInvalidAccent 主题色格式非法
|
||||
var ErrInvalidAccent = errors.New("主题色格式不正确,应为 #RRGGBB")
|
||||
|
||||
@@ -63,21 +95,26 @@ var accentHexRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||
|
||||
// PublicSiteSettings 对前台公开的站点配置(SEO / 注册入口 / 发帖冷静期提示)
|
||||
type PublicSiteSettings 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"`
|
||||
}
|
||||
|
||||
// SettingService 站点级键值设置
|
||||
@@ -147,6 +184,11 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
||||
AnimSmoothScroll: true,
|
||||
AnimChrome: true,
|
||||
PostLinkNewTab: true,
|
||||
AttachmentExtLimit: true,
|
||||
AttachmentExts: append([]string(nil), DefaultAttachmentExts...),
|
||||
AttachmentMaxMB: DefaultAttachmentMaxMB,
|
||||
AttachmentMaxCount: DefaultAttachmentMaxCount,
|
||||
ImageMaxMB: DefaultImageMaxMB,
|
||||
}
|
||||
accent, err := s.AccentColor()
|
||||
if err != nil {
|
||||
@@ -244,6 +286,36 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
||||
return out, err
|
||||
}
|
||||
out.PostLinkNewTab = linkNewTab
|
||||
|
||||
extLimit, err := s.AttachmentExtLimit()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.AttachmentExtLimit = extLimit
|
||||
|
||||
exts, err := s.AttachmentExts()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.AttachmentExts = exts
|
||||
|
||||
attMB, err := s.AttachmentMaxMB()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.AttachmentMaxMB = attMB
|
||||
|
||||
attCount, err := s.AttachmentMaxCount()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.AttachmentMaxCount = attCount
|
||||
|
||||
imgMB, err := s.ImageMaxMB()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.ImageMaxMB = imgMB
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -480,3 +552,218 @@ func (s *SettingService) PostLinkNewTab() (bool, error) {
|
||||
func (s *SettingService) SetPostLinkNewTab(on bool) error {
|
||||
return s.setBoolDefaultTrue(SettingKeyPostLinkNewTab, on)
|
||||
}
|
||||
|
||||
// AttachmentExtLimit 是否限制附件扩展名。缺行视为开启。
|
||||
func (s *SettingService) AttachmentExtLimit() (bool, error) {
|
||||
v, found, err := s.getValue(SettingKeyAttachmentExtLimit)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
return parseBoolDefaultTrue(v, found), nil
|
||||
}
|
||||
|
||||
func (s *SettingService) SetAttachmentExtLimit(on bool) error {
|
||||
return s.setBoolDefaultTrue(SettingKeyAttachmentExtLimit, on)
|
||||
}
|
||||
|
||||
// NormalizeAttachmentExts 规范化扩展名列表:去点、小写、去重、校验格式。
|
||||
func NormalizeAttachmentExts(raw []string) ([]string, error) {
|
||||
seen := make(map[string]struct{}, len(raw))
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
e := strings.TrimSpace(strings.ToLower(item))
|
||||
e = strings.TrimPrefix(e, ".")
|
||||
if e == "" {
|
||||
continue
|
||||
}
|
||||
if !attachmentExtRe.MatchString(e) {
|
||||
return nil, ErrInvalidSiteSetting
|
||||
}
|
||||
if _, ok := seen[e]; ok {
|
||||
continue
|
||||
}
|
||||
seen[e] = struct{}{}
|
||||
out = append(out, e)
|
||||
if len(out) > MaxAttachmentExtCount {
|
||||
return nil, ErrInvalidSiteSetting
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseExtList(v string) []string {
|
||||
parts := strings.FieldsFunc(v, func(r rune) bool {
|
||||
return r == ',' || r == ' ' || r == ';' || r == '\n' || r == '\t'
|
||||
})
|
||||
out, err := NormalizeAttachmentExts(parts)
|
||||
if err != nil || len(out) == 0 {
|
||||
return append([]string(nil), DefaultAttachmentExts...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func extsEqualDefault(exts []string) bool {
|
||||
if len(exts) != len(DefaultAttachmentExts) {
|
||||
return false
|
||||
}
|
||||
for i := range exts {
|
||||
if exts[i] != DefaultAttachmentExts[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// AttachmentExts 允许的扩展名(无点)。缺行=默认列表;显式空串=空列表。
|
||||
func (s *SettingService) AttachmentExts() ([]string, error) {
|
||||
v, found, err := s.getValue(SettingKeyAttachmentExts)
|
||||
if err != nil {
|
||||
return append([]string(nil), DefaultAttachmentExts...), err
|
||||
}
|
||||
if !found {
|
||||
return append([]string(nil), DefaultAttachmentExts...), nil
|
||||
}
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
return parseExtList(v), nil
|
||||
}
|
||||
|
||||
// SetAttachmentExts 保存扩展名列表;与默认完全一致时删键。空列表写入空串(限制开启时表示禁止上传)。
|
||||
func (s *SettingService) SetAttachmentExts(exts []string) error {
|
||||
norm, err := NormalizeAttachmentExts(exts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(norm) == 0 {
|
||||
return s.putValue(SettingKeyAttachmentExts, "")
|
||||
}
|
||||
if extsEqualDefault(norm) {
|
||||
return s.deleteKey(SettingKeyAttachmentExts)
|
||||
}
|
||||
return s.putValue(SettingKeyAttachmentExts, strings.Join(norm, ","))
|
||||
}
|
||||
|
||||
// AttachmentExtAllowed 当前设置下扩展名(含点或不含点)是否允许上传。
|
||||
func (s *SettingService) AttachmentExtAllowed(ext string) (bool, error) {
|
||||
limit, err := s.AttachmentExtLimit()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !limit {
|
||||
return true, nil
|
||||
}
|
||||
e := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".")
|
||||
if e == "" {
|
||||
return false, nil
|
||||
}
|
||||
list, err := s.AttachmentExts()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, x := range list {
|
||||
if x == e {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// AttachmentMaxMB 附件单文件上限(MB)。缺行=20;最小 1,无硬顶。
|
||||
func (s *SettingService) AttachmentMaxMB() (int, error) {
|
||||
v, found, err := s.getValue(SettingKeyAttachmentMaxMB)
|
||||
if err != nil {
|
||||
return DefaultAttachmentMaxMB, err
|
||||
}
|
||||
if !found {
|
||||
return DefaultAttachmentMaxMB, nil
|
||||
}
|
||||
n, convErr := strconv.Atoi(strings.TrimSpace(v))
|
||||
if convErr != nil || n < MinAttachmentMaxMB {
|
||||
return DefaultAttachmentMaxMB, nil
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *SettingService) SetAttachmentMaxMB(mb int) error {
|
||||
if mb < MinAttachmentMaxMB {
|
||||
return ErrInvalidSiteSetting
|
||||
}
|
||||
if mb == DefaultAttachmentMaxMB {
|
||||
return s.deleteKey(SettingKeyAttachmentMaxMB)
|
||||
}
|
||||
return s.putValue(SettingKeyAttachmentMaxMB, strconv.Itoa(mb))
|
||||
}
|
||||
|
||||
// AttachmentMaxBytes 附件字节上限(按当前 MB 设置)。
|
||||
func (s *SettingService) AttachmentMaxBytes() (int64, error) {
|
||||
mb, err := s.AttachmentMaxMB()
|
||||
if err != nil {
|
||||
return int64(DefaultAttachmentMaxMB) << 20, err
|
||||
}
|
||||
return int64(mb) << 20, nil
|
||||
}
|
||||
|
||||
// AttachmentMaxCount 每帖/草稿附件个数上限。缺行=10;范围 1–20。
|
||||
func (s *SettingService) AttachmentMaxCount() (int, error) {
|
||||
v, found, err := s.getValue(SettingKeyAttachmentMaxCount)
|
||||
if err != nil {
|
||||
return DefaultAttachmentMaxCount, err
|
||||
}
|
||||
if !found {
|
||||
return DefaultAttachmentMaxCount, nil
|
||||
}
|
||||
n, convErr := strconv.Atoi(strings.TrimSpace(v))
|
||||
if convErr != nil || n < MinAttachmentMaxCount {
|
||||
return DefaultAttachmentMaxCount, nil
|
||||
}
|
||||
if n > MaxAttachmentMaxCount {
|
||||
return MaxAttachmentMaxCount, nil
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *SettingService) SetAttachmentMaxCount(n int) error {
|
||||
if n < MinAttachmentMaxCount || n > MaxAttachmentMaxCount {
|
||||
return ErrInvalidSiteSetting
|
||||
}
|
||||
if n == DefaultAttachmentMaxCount {
|
||||
return s.deleteKey(SettingKeyAttachmentMaxCount)
|
||||
}
|
||||
return s.putValue(SettingKeyAttachmentMaxCount, strconv.Itoa(n))
|
||||
}
|
||||
|
||||
// ImageMaxMB 正文插图上限(MB)。缺行=5;最小 1,无硬顶。
|
||||
func (s *SettingService) ImageMaxMB() (int, error) {
|
||||
v, found, err := s.getValue(SettingKeyImageMaxMB)
|
||||
if err != nil {
|
||||
return DefaultImageMaxMB, err
|
||||
}
|
||||
if !found {
|
||||
return DefaultImageMaxMB, nil
|
||||
}
|
||||
n, convErr := strconv.Atoi(strings.TrimSpace(v))
|
||||
if convErr != nil || n < MinImageMaxMB {
|
||||
return DefaultImageMaxMB, nil
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *SettingService) SetImageMaxMB(mb int) error {
|
||||
if mb < MinImageMaxMB {
|
||||
return ErrInvalidSiteSetting
|
||||
}
|
||||
if mb == DefaultImageMaxMB {
|
||||
return s.deleteKey(SettingKeyImageMaxMB)
|
||||
}
|
||||
return s.putValue(SettingKeyImageMaxMB, strconv.Itoa(mb))
|
||||
}
|
||||
|
||||
// ImageMaxBytes 插图像素字节上限。
|
||||
func (s *SettingService) ImageMaxBytes() (int64, error) {
|
||||
mb, err := s.ImageMaxMB()
|
||||
if err != nil {
|
||||
return int64(DefaultImageMaxMB) << 20, err
|
||||
}
|
||||
return int64(mb) << 20, nil
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ import (
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
@@ -31,14 +33,32 @@ const (
|
||||
|
||||
// UploadService 附件上传:落盘到 data/uploads,元信息入库 attachments
|
||||
type UploadService struct {
|
||||
db *gorm.DB
|
||||
dir string // 上传根目录(如 data/uploads)
|
||||
db *gorm.DB
|
||||
dir string // 上传根目录(如 data/uploads)
|
||||
setting *SettingService
|
||||
}
|
||||
|
||||
func NewUploadService(db *gorm.DB, uploadDir string) *UploadService {
|
||||
return &UploadService{db: db, dir: uploadDir}
|
||||
}
|
||||
|
||||
// WithSetting 注入站点设置(插图体积上限)
|
||||
func (s *UploadService) WithSetting(setting *SettingService) *UploadService {
|
||||
s.setting = setting
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *UploadService) imageMaxBytes() int64 {
|
||||
if s.setting == nil {
|
||||
return ImageMaxBytes
|
||||
}
|
||||
n, err := s.setting.ImageMaxBytes()
|
||||
if err != nil || n < 1 {
|
||||
return ImageMaxBytes
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// EnsureDir 启动时确保上传目录存在
|
||||
func (s *UploadService) EnsureDir() error {
|
||||
if err := os.MkdirAll(filepath.Join(s.dir, "avatars"), 0o755); err != nil {
|
||||
@@ -125,8 +145,7 @@ func detectImageFormat(data []byte) (imageFormat, error) {
|
||||
return imageFormat{}, errors.New("仅支持 JPEG / PNG / WebP")
|
||||
}
|
||||
|
||||
func decodeImageSize(data []byte, mime string) (w, h int, err error) {
|
||||
r := bytes.NewReader(data)
|
||||
func decodeImageSizeReader(r io.Reader, mime string) (w, h int, err error) {
|
||||
var cfg image.Config
|
||||
switch mime {
|
||||
case "image/webp":
|
||||
@@ -140,28 +159,33 @@ func decodeImageSize(data []byte, mime string) (w, h int, err error) {
|
||||
return cfg.Width, cfg.Height, nil
|
||||
}
|
||||
|
||||
// SaveImage 保存帖子插图:校验格式/大小/尺寸 → 落盘 → 写 attachments(kind=image)
|
||||
func (s *UploadService) SaveImage(userID uint, data []byte) (*model.Attachment, error) {
|
||||
if len(data) == 0 {
|
||||
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → 落盘 → 写 attachments(kind=image)
|
||||
func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment, error) {
|
||||
if src == nil {
|
||||
return nil, errors.New("文件为空")
|
||||
}
|
||||
if len(data) > ImageMaxBytes {
|
||||
return nil, errors.New("图片不能超过 5MB")
|
||||
maxB := s.imageMaxBytes()
|
||||
tooLarge := func() error {
|
||||
mb := int(maxB >> 20)
|
||||
if mb < 1 {
|
||||
mb = 1
|
||||
}
|
||||
return errors.New("图片不能超过 " + strconv.Itoa(mb) + "MB")
|
||||
}
|
||||
format, err := detectImageFormat(data)
|
||||
|
||||
// 先读魔数头,再与剩余流合并落盘,整图不进内存
|
||||
head := make([]byte, 12)
|
||||
n, err := io.ReadFull(src, head)
|
||||
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("读取图片失败")
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, errors.New("文件为空")
|
||||
}
|
||||
format, err := detectImageFormat(head[:n])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w, h, err := decodeImageSize(data, format.mime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if w < 1 || h < 1 {
|
||||
return nil, errors.New("无效的图片尺寸")
|
||||
}
|
||||
if w > ImageMaxDim || h > ImageMaxDim {
|
||||
return nil, errors.New("图片边长不能超过 4096px")
|
||||
}
|
||||
|
||||
nameBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(nameBytes); err != nil {
|
||||
@@ -169,7 +193,61 @@ func (s *UploadService) SaveImage(userID uint, data []byte) (*model.Attachment,
|
||||
}
|
||||
filename := hex.EncodeToString(nameBytes) + format.ext
|
||||
fullPath := filepath.Join(s.dir, "images", filename)
|
||||
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
|
||||
tmp := fullPath + ".partial"
|
||||
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remain := maxB + 1 - int64(n)
|
||||
if remain < 0 {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return nil, tooLarge()
|
||||
}
|
||||
writtenHead, err := f.Write(head[:n])
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return nil, err
|
||||
}
|
||||
writtenRest, copyErr := io.Copy(f, io.LimitReader(src, remain))
|
||||
_ = f.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, copyErr
|
||||
}
|
||||
total := int64(writtenHead) + writtenRest
|
||||
if total == 0 {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, errors.New("文件为空")
|
||||
}
|
||||
if total > maxB {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, tooLarge()
|
||||
}
|
||||
|
||||
rf, err := os.Open(tmp)
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, err
|
||||
}
|
||||
w, h, err := decodeImageSizeReader(rf, format.mime)
|
||||
_ = rf.Close()
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, err
|
||||
}
|
||||
if w < 1 || h < 1 {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, errors.New("无效的图片尺寸")
|
||||
}
|
||||
if w > ImageMaxDim || h > ImageMaxDim {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, errors.New("图片边长不能超过 4096px")
|
||||
}
|
||||
if err := os.Rename(tmp, fullPath); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -178,7 +256,7 @@ func (s *UploadService) SaveImage(userID uint, data []byte) (*model.Attachment,
|
||||
Kind: model.AttachmentKindImage,
|
||||
URL: "/uploads/images/" + filename,
|
||||
MIME: format.mime,
|
||||
Size: len(data),
|
||||
Size: int(total),
|
||||
Width: w,
|
||||
Height: h,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user