Files
jiang13-bbs/backend/service/fileallow.go
freefire 61bf9fb48c feat(attach): 附件可配置限额、流式落盘与分阶段上传
管理端可配类型/体积;上传改流式避免整文件进内存;发帖先选再传并拦截未上传提交;提高 Next proxy 体积上限。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 07:33:16 +08:00

177 lines
4.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}