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

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