feat(attach): 附件可配置限额、流式落盘与分阶段上传

管理端可配类型/体积;上传改流式避免整文件进内存;发帖先选再传并拦截未上传提交;提高 Next proxy 体积上限。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 07:33:16 +08:00
parent 6cc434f103
commit 61bf9fb48c
24 changed files with 2513 additions and 307 deletions

View File

@@ -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
}