195 lines
6.0 KiB
Go
195 lines
6.0 KiB
Go
// Package upload 对应 config.php 中的 handleUpload() 与路径辅助函数。
|
||
package upload
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// 上传错误码(对应 config.php UPLOAD_ERR_* 常量)
|
||
const (
|
||
ErrNoFileReceived = 1001
|
||
ErrFileFieldMissing = 1002
|
||
ErrHandleFailed = 1003
|
||
ErrRawBodySave = 1004
|
||
ErrAlbumNotFound = 1005
|
||
ErrNotLoggedIn = 1006
|
||
ErrNotBound = 1007
|
||
ErrMethodNotAllowed = 1008
|
||
ErrPartnerInvalid = 1009
|
||
ErrFileTooLarge = 1010
|
||
)
|
||
|
||
var allowedExts = map[string]bool{"jpg": true, "jpeg": true, "png": true, "gif": true, "webp": true}
|
||
|
||
// ImageChecker 图片内容安全检测(由 wechat 包实现)
|
||
type ImageChecker interface {
|
||
CheckImage(filePath, openid string) (bool, string)
|
||
}
|
||
|
||
type Uploader struct {
|
||
// Dir 上传根目录(UPLOAD_DIR)
|
||
Dir string
|
||
MaxSize int64
|
||
Checker ImageChecker
|
||
}
|
||
|
||
// Result 对应 handleUpload 返回的 ['path' => ...] 或 ['error' => ...]
|
||
type Result struct {
|
||
Path string // 相对路径,如 uploads/avatars/520001/20260101120000_ab12cd34.jpg
|
||
Error string
|
||
}
|
||
|
||
// AvatarPath 对应 avatarPath($displayId) → avatars/{displayId}
|
||
func AvatarPath(userID string) string { return "avatars/" + userID }
|
||
|
||
// CouplePath 对应 couplePath($spaceId, $type)
|
||
func CouplePath(spaceID, typ string) string {
|
||
if typ != "" {
|
||
return "couples/" + spaceID + "/" + typ
|
||
}
|
||
return "couples/" + spaceID
|
||
}
|
||
|
||
// UserFilePath 对应 userFilePath($userId, $type) → users/{userId}/{type}
|
||
func UserFilePath(userID int64, typ string) string {
|
||
p := fmt.Sprintf("users/%d", userID)
|
||
if typ != "" {
|
||
p += "/" + typ
|
||
}
|
||
return p
|
||
}
|
||
|
||
// NewFilename 对应 date('YmdHis') . '_' . bin2hex(random_bytes(4)) . '.' . ext
|
||
func NewFilename(ext string) string {
|
||
b := make([]byte, 4)
|
||
rand.Read(b)
|
||
return time.Now().Format("20060102150405") + "_" + hex.EncodeToString(b) + "." + ext
|
||
}
|
||
|
||
// Ext 取小写扩展名(不含点)
|
||
func Ext(name string) string {
|
||
return strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
|
||
}
|
||
|
||
// Handle 对应 handleUpload($file, $subDir, $prefixPath)
|
||
func (u *Uploader) Handle(fh *multipart.FileHeader, subDir, prefixPath string) Result {
|
||
if fh == nil {
|
||
return Result{Error: "未接收到文件"}
|
||
}
|
||
f, err := fh.Open()
|
||
if err != nil {
|
||
return Result{Error: "未接收到文件"}
|
||
}
|
||
defer f.Close()
|
||
return u.HandleReader(f, fh.Filename, fh.Size, subDir, prefixPath)
|
||
}
|
||
|
||
// HandleReader 与 Handle 相同的校验流程(大小/扩展名/MIME),输入为任意可 seek 的数据源
|
||
func (u *Uploader) HandleReader(f io.ReadSeeker, filename string, size int64, subDir, prefixPath string) Result {
|
||
if size > u.MaxSize {
|
||
return Result{Error: fmt.Sprintf("文件超过大小限制(%dMB)", u.MaxSize/1024/1024)}
|
||
}
|
||
ext := Ext(filename)
|
||
if !allowedExts[ext] {
|
||
return Result{Error: "不支持的文件类型(." + ext + "),仅支持: jpg,jpeg,png,gif,webp"}
|
||
}
|
||
// MIME 检测(对应 finfo):仅当既不是 image/* 也不是 octet-stream 时拒绝
|
||
head := make([]byte, 512)
|
||
n, _ := io.ReadFull(f, head)
|
||
// finfo 只返回主类型(不带 "; charset=...")
|
||
mime := http.DetectContentType(head[:n])
|
||
if i := strings.Index(mime, ";"); i >= 0 {
|
||
mime = strings.TrimSpace(mime[:i])
|
||
}
|
||
if !strings.HasPrefix(mime, "image/") && mime != "application/octet-stream" {
|
||
return Result{Error: "文件MIME类型不匹配: " + mime}
|
||
}
|
||
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||
return Result{Error: "文件读取失败"}
|
||
}
|
||
return u.save(f, "", ext, subDir, prefixPath)
|
||
}
|
||
|
||
// HandleBytes 对 HandleReader 的便捷封装(原始请求体 → 伪装成 $_FILES 走完整校验)
|
||
func (u *Uploader) HandleBytes(data []byte, filename, subDir, prefixPath string) Result {
|
||
return u.HandleReader(bytes.NewReader(data), filename, int64(len(data)), subDir, prefixPath)
|
||
}
|
||
|
||
// SaveRaw 保存原始二进制数据(photo_upload / set_bg_image 的 RAW BODY 兜底路径),不做扩展名/MIME 校验。
|
||
// namePrefix 为文件名前缀(如 "bg_")。
|
||
func (u *Uploader) SaveRaw(data []byte, namePrefix, ext, subDir, prefixPath string) Result {
|
||
return u.save(bytes.NewReader(data), namePrefix, ext, subDir, prefixPath)
|
||
}
|
||
|
||
func (u *Uploader) save(src io.Reader, namePrefix, ext, subDir, prefixPath string) Result {
|
||
rel := "uploads/"
|
||
dir := u.Dir
|
||
if prefixPath != "" {
|
||
rel += prefixPath + "/"
|
||
dir = filepath.Join(dir, filepath.FromSlash(prefixPath))
|
||
}
|
||
if subDir != "" {
|
||
rel += subDir + "/"
|
||
dir = filepath.Join(dir, filepath.FromSlash(subDir))
|
||
}
|
||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||
return Result{Error: "文件保存失败。无法写入目录: " + dir + "; 系统错误: " + err.Error()}
|
||
}
|
||
name := namePrefix + NewFilename(ext)
|
||
dest := filepath.Join(dir, name)
|
||
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||
if err != nil {
|
||
return Result{Error: "文件保存失败。无法写入目录: " + dir + "; 系统错误: " + err.Error()}
|
||
}
|
||
if _, err := io.Copy(out, src); err != nil {
|
||
out.Close()
|
||
os.Remove(dest)
|
||
return Result{Error: "文件保存失败。系统错误: " + err.Error()}
|
||
}
|
||
out.Close()
|
||
slog.Info("upload saved", "path", rel+name)
|
||
|
||
if u.Checker != nil {
|
||
if pass, msg := u.Checker.CheckImage(dest, ""); !pass {
|
||
os.Remove(dest)
|
||
return Result{Error: msg}
|
||
}
|
||
}
|
||
return Result{Path: rel + name}
|
||
}
|
||
|
||
// Remove 删除 uploads 下的文件(path 为 DB 中存的相对路径 uploads/...);忽略错误。
|
||
func (u *Uploader) Remove(relPath string) {
|
||
if relPath == "" || strings.Contains(relPath, "..") {
|
||
return
|
||
}
|
||
p := strings.TrimPrefix(relPath, "uploads/")
|
||
if p == relPath {
|
||
return
|
||
}
|
||
os.Remove(filepath.Join(u.Dir, filepath.FromSlash(p)))
|
||
}
|
||
|
||
// Abs 返回相对路径对应的绝对路径
|
||
func (u *Uploader) Abs(relPath string) string {
|
||
p := strings.TrimPrefix(relPath, "uploads/")
|
||
return filepath.Join(u.Dir, filepath.FromSlash(p))
|
||
}
|
||
|
||
// Exists 文件是否存在
|
||
func (u *Uploader) Exists(relPath string) bool {
|
||
st, err := os.Stat(u.Abs(relPath))
|
||
return err == nil && !st.IsDir()
|
||
}
|