管理端可配类型/体积;上传改流式避免整文件进内存;发帖先选再传并拦截未上传提交;提高 Next proxy 体积上限。 Co-authored-by: Cursor <cursoragent@cursor.com>
177 lines
4.2 KiB
Go
177 lines
4.2 KiB
Go
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))
|
||
}
|