feat(attach): 附件可配置限额、流式落盘与分阶段上传
管理端可配类型/体积;上传改流式避免整文件进内存;发帖先选再传并拦截未上传提交;提高 Next proxy 体积上限。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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))
|
||||
}
|
||||
Reference in New Issue
Block a user