gengx
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxUploadBytes int64 = 16 << 20
|
||||
|
||||
var mediaNamePattern = regexp.MustCompile(`^[0-9]+-[0-9]+\.(?:gif|jpe?g|png|webp|mp3|wav|amr|m4a)$`)
|
||||
|
||||
func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) {
|
||||
// Multipart boundaries and headers add a small amount of overhead. Keep the
|
||||
// actual file limit at 16 MiB without rejecting a file that is exactly at
|
||||
// that limit solely because of the multipart envelope.
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+(1<<20))
|
||||
if err := r.ParseMultipartForm(maxUploadBytes); err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "媒体文件不得超过 16MB")
|
||||
return
|
||||
}
|
||||
|
||||
file, fileHeader, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "请选择媒体文件")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
size, err := file.Seek(0, io.SeekEnd)
|
||||
if err != nil || size <= 0 || size > maxUploadBytes {
|
||||
fail(w, http.StatusBadRequest, 20001, "媒体文件大小无效或超过 16MB")
|
||||
return
|
||||
}
|
||||
if _, err = file.Seek(0, io.SeekStart); err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "无法读取媒体文件")
|
||||
return
|
||||
}
|
||||
header := make([]byte, min(size, 512))
|
||||
read, err := io.ReadFull(file, header)
|
||||
if err != nil && err != io.ErrUnexpectedEOF {
|
||||
fail(w, http.StatusBadRequest, 20001, "无法读取媒体文件")
|
||||
return
|
||||
}
|
||||
header = header[:read]
|
||||
extensions := map[string]string{
|
||||
"image/gif": ".gif",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/mp3": ".mp3",
|
||||
"audio/wav": ".wav",
|
||||
"audio/x-wav": ".wav",
|
||||
"audio/amr": ".amr",
|
||||
"audio/mp4": ".m4a",
|
||||
}
|
||||
contentType := http.DetectContentType(header)
|
||||
extension, ok := extensions[contentType]
|
||||
if !ok {
|
||||
fail(w, http.StatusBadRequest, 20001, "仅支持常见图片或语音格式")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = file.Seek(0, io.SeekStart); err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "无法读取媒体文件")
|
||||
return
|
||||
}
|
||||
if err = a.validateStorageProviderConfig(r.Context()); err != nil {
|
||||
fail(w, http.StatusServiceUnavailable, 50001, "文件存储尚未正确配置")
|
||||
return
|
||||
}
|
||||
|
||||
provider := a.configPlain(r.Context(), "storage.provider", "local")
|
||||
name := fmt.Sprintf("%d-%d%s", current(r).ID, time.Now().UnixNano(), extension)
|
||||
objectKey := name
|
||||
if provider != "local" {
|
||||
prefix := strings.Trim(a.configPlain(r.Context(), "storage.object_prefix", "media"), "/")
|
||||
objectKey = fmt.Sprintf("%s/%s/%s", prefix, time.Now().Format("2006/01"), name)
|
||||
}
|
||||
mediaType := "audio"
|
||||
if strings.HasPrefix(contentType, "image/") {
|
||||
mediaType = "image"
|
||||
}
|
||||
|
||||
var bucket, publicURL string
|
||||
var storage mediaObjectStorage
|
||||
closeStorage := func() {}
|
||||
if provider == "local" {
|
||||
baseURL := strings.TrimSpace(a.configPlain(r.Context(), "storage.local.public_base_url", ""))
|
||||
if baseURL == "" {
|
||||
scheme := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0])
|
||||
if scheme != "https" {
|
||||
scheme = "http"
|
||||
}
|
||||
baseURL = fmt.Sprintf("%s://%s/uploads", scheme, r.Host)
|
||||
}
|
||||
publicURL = storagePublicURL(baseURL, objectKey)
|
||||
} else {
|
||||
storage, closeStorage, err = a.newMediaObjectStorage(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("storage provider init failed provider=%s error=%v", provider, err)
|
||||
fail(w, http.StatusServiceUnavailable, 50001, "文件存储连接初始化失败")
|
||||
return
|
||||
}
|
||||
defer closeStorage()
|
||||
bucket = storage.Bucket()
|
||||
publicURL = storagePublicURL(a.configPlain(r.Context(), "storage."+provider+".public_base_url", ""), objectKey)
|
||||
}
|
||||
if len(publicURL) > 500 || len(objectKey) > 500 {
|
||||
fail(w, http.StatusServiceUnavailable, 50001, "文件存储公开地址过长")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := a.db.ExecContext(r.Context(), `INSERT INTO media_assets(owner_user_id,media_type,storage_provider,bucket,object_key,public_url,mime_type,file_size,moderation_status,status) VALUES(?,?,?,?,?,'',?,?,1,0)`, current(r).ID, mediaType, provider, bucket, objectKey, contentType, size)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "创建媒体记录失败")
|
||||
return
|
||||
}
|
||||
mediaID, _ := result.LastInsertId()
|
||||
cleanupRecord := func() {
|
||||
_, _ = a.db.ExecContext(r.Context(), `DELETE FROM media_assets WHERE id=? AND status=0`, mediaID)
|
||||
}
|
||||
|
||||
if provider == "local" {
|
||||
directory := a.configPlain(r.Context(), "storage.local.directory", a.config.MediaDir)
|
||||
if err = os.MkdirAll(directory, 0o755); err == nil {
|
||||
target := filepath.Join(directory, filepath.Base(objectKey))
|
||||
var destination *os.File
|
||||
destination, err = os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||
if err == nil {
|
||||
_, err = io.Copy(destination, file)
|
||||
closeErr := destination.Close()
|
||||
if err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
_ = os.Remove(target)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
err = storage.Put(r.Context(), objectKey, contentType, size, file)
|
||||
}
|
||||
if err != nil {
|
||||
cleanupRecord()
|
||||
log.Printf("media upload failed provider=%s bucket=%s object=%s original=%q error=%v", provider, bucket, objectKey, filepath.Base(fileHeader.Filename), err)
|
||||
fail(w, http.StatusBadGateway, 50001, "文件上传失败,请检查存储配置后重试")
|
||||
return
|
||||
}
|
||||
if _, err = a.db.ExecContext(r.Context(), `UPDATE media_assets SET public_url=?,status=1 WHERE id=? AND status=0`, publicURL, mediaID); err != nil {
|
||||
if provider == "local" {
|
||||
_ = os.Remove(filepath.Join(a.configPlain(r.Context(), "storage.local.directory", a.config.MediaDir), filepath.Base(objectKey)))
|
||||
} else {
|
||||
_ = storage.Delete(r.Context(), objectKey)
|
||||
}
|
||||
cleanupRecord()
|
||||
fail(w, http.StatusInternalServerError, 50001, "完成媒体记录失败")
|
||||
return
|
||||
}
|
||||
reply(w, map[string]any{
|
||||
"id": mediaID,
|
||||
"name": name,
|
||||
"url": publicURL,
|
||||
"provider": provider,
|
||||
"objectKey": objectKey,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) serveMedia(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
name := parts[len(parts)-1]
|
||||
if !mediaNamePattern.MatchString(name) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
directory := a.configPlain(r.Context(), "storage.local.directory", a.config.MediaDir)
|
||||
http.ServeFile(w, r, filepath.Join(directory, filepath.Base(name)))
|
||||
}
|
||||
Reference in New Issue
Block a user