291 lines
8.1 KiB
Go
291 lines
8.1 KiB
Go
// Package upload 文件上传
|
||
//
|
||
// 采用两段式流程:先落入临时目录,提交保存时转正至正式目录。
|
||
// 1. SaveTemp:上传到 {uploadDir}/{tempDir}/日期/ 目录,含白名单与大小校验
|
||
// 2. SaveFromTemp:表单提交保存时,将临时文件移动到正式子目录(如 user/article)
|
||
//
|
||
// 路径契约:数据库中统一存储相对 uploadDir 的路径,例如 "tenant/20260923/x.png";
|
||
// 对外访问地址由 GetFileUrl 拼接 file.domainUrl 生成。
|
||
package upload
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
"github.com/gogf/gf/v2/net/ghttp"
|
||
)
|
||
|
||
// cfgType 文件上传配置
|
||
//
|
||
// 对应 manifest/config/config.yaml 中的 file 配置项
|
||
type cfgType struct {
|
||
UploadDir string // 上传根目录
|
||
TempDir string // 临时目录名
|
||
DomainUrl string // 文件访问域名
|
||
ImageExt string // 允许的图片扩展名
|
||
FileExt string // 允许的文件扩展名
|
||
MaxSizeMB int // 单文件大小上限(MB)
|
||
}
|
||
|
||
// loadCfg 加载文件上传配置
|
||
//
|
||
// 临时目录名缺省为 temp,大小上限缺省为 10MB
|
||
func loadCfg(ctx context.Context) cfgType {
|
||
c := cfgType{
|
||
UploadDir: g.Cfg().MustGet(ctx, "file.uploadDir").String(),
|
||
TempDir: g.Cfg().MustGet(ctx, "file.tempDir").String(),
|
||
DomainUrl: g.Cfg().MustGet(ctx, "file.domainUrl").String(),
|
||
ImageExt: g.Cfg().MustGet(ctx, "file.imageExt").String(),
|
||
FileExt: g.Cfg().MustGet(ctx, "file.fileExt").String(),
|
||
MaxSizeMB: g.Cfg().MustGet(ctx, "file.maxSize").Int(),
|
||
}
|
||
if c.TempDir == "" {
|
||
c.TempDir = "temp"
|
||
}
|
||
if c.MaxSizeMB <= 0 {
|
||
c.MaxSizeMB = 10
|
||
}
|
||
return c
|
||
}
|
||
|
||
// Result 上传结果
|
||
type Result struct {
|
||
Url string // 可访问地址:domainUrl + /temp/20261013/xxx.png
|
||
Path string // 相对 uploadDir 的路径,入库形态
|
||
Name string // 原始文件名
|
||
Size int64 // 文件大小(字节)
|
||
Ext string // 扩展名
|
||
Mime string // MIME 类型(按文件头内容嗅探)
|
||
}
|
||
|
||
// BaseDir 上传根目录
|
||
//
|
||
// 取配置 file.uploadDir,文件统一落盘于此
|
||
func BaseDir(ctx context.Context) string {
|
||
return loadCfg(ctx).UploadDir
|
||
}
|
||
|
||
// TempBaseDir 临时目录绝对路径
|
||
//
|
||
// 供清理任务使用
|
||
func TempBaseDir(ctx context.Context) string {
|
||
c := loadCfg(ctx)
|
||
return filepath.Join(c.UploadDir, c.TempDir)
|
||
}
|
||
|
||
// GetAccessBase 访问基址
|
||
//
|
||
// 取配置 file.domainUrl,即 uploadDir 的对外访问地址,不含末尾斜杠
|
||
func GetAccessBase(ctx context.Context) string {
|
||
return strings.TrimRight(loadCfg(ctx).DomainUrl, "/")
|
||
}
|
||
|
||
// GetFileUrl 相对路径转可访问地址
|
||
//
|
||
// 库中存储相对 uploadDir 的路径,输出为 file.domainUrl 加相对路径;
|
||
// 空值与已是完整 URL 的值原样返回
|
||
func GetFileUrl(ctx context.Context, path string) string {
|
||
if path == "" {
|
||
return ""
|
||
}
|
||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||
return path
|
||
}
|
||
return GetAccessBase(ctx) + "/" + strings.TrimLeft(path, "/")
|
||
}
|
||
|
||
// SaveTemp 上传到临时目录
|
||
//
|
||
// typ 为 file 或 image,分别按 fileExt 与 imageExt 白名单及 maxSize 校验;
|
||
// 图片类型额外做内容嗅探
|
||
func SaveTemp(ctx context.Context, file *ghttp.UploadFile, typ string) (*Result, error) {
|
||
if file == nil {
|
||
return nil, fmt.Errorf("请选择文件")
|
||
}
|
||
c := loadCfg(ctx)
|
||
|
||
// 1) 大小校验
|
||
if file.Size > int64(c.MaxSizeMB)*1024*1024 {
|
||
return nil, fmt.Errorf("文件大小超过限制(%dMB)", c.MaxSizeMB)
|
||
}
|
||
|
||
// 2) 扩展名白名单
|
||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(file.Filename), "."))
|
||
if ext == "" {
|
||
return nil, fmt.Errorf("无法识别文件扩展名")
|
||
}
|
||
allow := c.FileExt
|
||
if typ == "image" {
|
||
allow = c.ImageExt
|
||
}
|
||
if !extIn(ext, allow) {
|
||
return nil, fmt.Errorf("不允许的文件类型: .%s", ext)
|
||
}
|
||
|
||
// 3) 读取内容做图片嗅探(type=image 时)
|
||
src, err := file.Open()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer src.Close()
|
||
head := make([]byte, 512)
|
||
n, _ := io.ReadFull(src, head)
|
||
head = head[:n]
|
||
if typ == "image" && !isImage(head) {
|
||
return nil, fmt.Errorf("图片文件内容无效或已损坏")
|
||
}
|
||
// MIME 嗅探:按文件头内容识别,识别不出时回退通用二进制类型
|
||
mime := "application/octet-stream"
|
||
if n > 0 {
|
||
if m := http.DetectContentType(head); m != "" {
|
||
mime = m
|
||
}
|
||
}
|
||
if _, err = src.Seek(0, io.SeekStart); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 4) 落盘 temp/{日期}/{32位随机}.{ext}
|
||
dateDir := time.Now().Format("20060102")
|
||
relDir := filepath.Join(c.TempDir, dateDir)
|
||
fullDir := filepath.Join(c.UploadDir, relDir)
|
||
if err = os.MkdirAll(fullDir, 0o755); err != nil {
|
||
return nil, fmt.Errorf("创建上传目录失败: %v", err)
|
||
}
|
||
name := randomHex(16) + "." + ext
|
||
full := filepath.Join(fullDir, name)
|
||
dst, err := os.Create(full)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
size, err := io.Copy(dst, src)
|
||
if cerr := dst.Close(); err == nil {
|
||
err = cerr
|
||
}
|
||
if err != nil {
|
||
_ = os.Remove(full)
|
||
return nil, err
|
||
}
|
||
|
||
rel := filepath.ToSlash(filepath.Join(relDir, name))
|
||
return &Result{
|
||
Url: GetFileUrl(ctx, rel),
|
||
Path: rel,
|
||
Name: file.Filename,
|
||
Size: size,
|
||
Ext: ext,
|
||
Mime: mime,
|
||
}, nil
|
||
}
|
||
|
||
// SaveFromTemp 将临时文件转正
|
||
//
|
||
// 返回值即入库形态,为相对 uploadDir 的路径:
|
||
// - 空值返回空串
|
||
// - 本站 URL 或相对路径:剥离域名前缀,临时文件移动到 {uploadDir}/{saveDir}/{日期}/{时分秒}_{随机}.{ext}
|
||
// - 外部 URL:原样返回
|
||
func SaveFromTemp(ctx context.Context, fileUrl, saveDir string) string {
|
||
if fileUrl == "" {
|
||
return ""
|
||
}
|
||
c := loadCfg(ctx)
|
||
|
||
rel := fileUrl
|
||
// 剥离本站域名(domainUrl 配置),得到相对 uploadDir 的路径
|
||
stripped := false
|
||
if c.DomainUrl != "" && strings.HasPrefix(rel, c.DomainUrl) {
|
||
rel = strings.TrimPrefix(rel, c.DomainUrl)
|
||
stripped = true
|
||
}
|
||
rel = strings.TrimPrefix(rel, "/")
|
||
|
||
// 仅 temp 需要转移;已是正式相对路径则归一化后返回(域名已剥离)
|
||
if !strings.HasPrefix(rel, c.TempDir+"/") {
|
||
if !stripped && (strings.HasPrefix(rel, "http://") || strings.HasPrefix(rel, "https://")) {
|
||
return fileUrl // 外部URL(未匹配本站域名)原样保留
|
||
}
|
||
return rel
|
||
}
|
||
srcFull := filepath.Join(c.UploadDir, filepath.FromSlash(rel))
|
||
if _, err := os.Stat(srcFull); err != nil {
|
||
return rel // 文件不存在则维持相对路径(幂等)
|
||
}
|
||
|
||
ext := strings.TrimPrefix(filepath.Ext(rel), ".")
|
||
if ext == "" {
|
||
ext = "bin"
|
||
}
|
||
dateDir := time.Now().Format("20060102")
|
||
name := time.Now().Format("150405") + "_" + randomHex(4) + "." + ext
|
||
newRelDir := filepath.ToSlash(filepath.Join(strings.Trim(saveDir, "/"), dateDir))
|
||
if err := os.MkdirAll(filepath.Join(c.UploadDir, filepath.FromSlash(newRelDir)), 0o755); err != nil {
|
||
return rel
|
||
}
|
||
newRel := newRelDir + "/" + name
|
||
dstFull := filepath.Join(c.UploadDir, filepath.FromSlash(newRel))
|
||
if err := os.Rename(srcFull, dstFull); err != nil {
|
||
// 跨卷回退:复制后删源
|
||
if data, rerr := os.ReadFile(srcFull); rerr == nil {
|
||
if werr := os.WriteFile(dstFull, data, 0o644); werr == nil {
|
||
_ = os.Remove(srcFull)
|
||
} else {
|
||
return rel
|
||
}
|
||
} else {
|
||
return rel
|
||
}
|
||
}
|
||
return newRel
|
||
}
|
||
|
||
// SaveFromTempMulti 逗号分隔多文件批量转正
|
||
//
|
||
// 适用于 article.images 这类多图字段
|
||
func SaveFromTempMulti(ctx context.Context, fileUrls, saveDir string) string {
|
||
if fileUrls == "" {
|
||
return ""
|
||
}
|
||
parts := strings.Split(fileUrls, ",")
|
||
for i, p := range parts {
|
||
parts[i] = SaveFromTemp(ctx, strings.TrimSpace(p), saveDir)
|
||
}
|
||
return strings.Join(parts, ",")
|
||
}
|
||
|
||
// extIn 判断扩展名是否在逗号分隔的白名单内
|
||
func extIn(ext, allow string) bool {
|
||
for _, a := range strings.Split(allow, ",") {
|
||
if strings.TrimSpace(a) == ext {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// isImage 按魔数嗅探常见图片格式
|
||
func isImage(b []byte) bool {
|
||
if len(b) < 4 {
|
||
return false
|
||
}
|
||
mime := http.DetectContentType(b)
|
||
return strings.HasPrefix(mime, "image/")
|
||
}
|
||
|
||
// randomHex 生成随机十六进制串
|
||
//
|
||
// n 字节随机数转为 2n 位十六进制字符,使用加密随机源防猜测
|
||
func randomHex(n int) string {
|
||
b := make([]byte, n)
|
||
_, _ = rand.Read(b)
|
||
return hex.EncodeToString(b)
|
||
}
|