更新
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -14,7 +16,23 @@ import (
|
||||
|
||||
const maxUploadBytes int64 = 16 << 20
|
||||
|
||||
var mediaNamePattern = regexp.MustCompile(`^[0-9]+-[0-9]+\.(?:gif|jpe?g|png|webp|mp3|wav|amr|m4a)$`)
|
||||
var mediaNamePattern = regexp.MustCompile(`^[0-9]+-[0-9]+(?:-av1(?:-thumb|-original)?|-im1(?:-thumb)?)?\.(?:gif|jpe?g|png|webp|mp3|wav|amr|m4a)$`)
|
||||
|
||||
func mediaThumbnailURL(source string) string {
|
||||
if source == "" || strings.ContainsAny(source, "?#") {
|
||||
return source
|
||||
}
|
||||
lower := strings.ToLower(source)
|
||||
if strings.HasSuffix(lower, "-im1-thumb.jpg") {
|
||||
return source
|
||||
}
|
||||
for _, extension := range []string{".jpeg", ".jpg", ".png", ".webp", ".gif"} {
|
||||
if strings.HasSuffix(lower, "-im1"+extension) {
|
||||
return source[:len(source)-len(extension)] + "-thumb.jpg"
|
||||
}
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) {
|
||||
// Multipart boundaries and headers add a small amount of overhead. Keep the
|
||||
@@ -25,6 +43,12 @@ func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, http.StatusBadRequest, 20001, "媒体文件不得超过 16MB")
|
||||
return
|
||||
}
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
purpose := r.FormValue("purpose")
|
||||
if purpose != "" && purpose != "avatar" {
|
||||
fail(w, http.StatusBadRequest, 20001, "上传用途无效")
|
||||
return
|
||||
}
|
||||
|
||||
file, fileHeader, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
@@ -76,6 +100,32 @@ func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, http.StatusServiceUnavailable, 50001, "文件存储尚未正确配置")
|
||||
return
|
||||
}
|
||||
originalType, originalExtension, originalSize := contentType, extension, size
|
||||
isImage := strings.HasPrefix(contentType, "image/")
|
||||
var imageVariants avatarImages
|
||||
if isImage {
|
||||
imageVariants, err = createAvatarImages(r.Context(), file)
|
||||
if err != nil {
|
||||
if errors.Is(err, errAvatarBusy) {
|
||||
w.Header().Set("Retry-After", "2")
|
||||
fail(w, http.StatusServiceUnavailable, 50001, strings.ReplaceAll(err.Error(), "头像", "图片"))
|
||||
} else {
|
||||
fail(w, http.StatusBadRequest, 20001, strings.ReplaceAll(err.Error(), "头像", "图片"))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if purpose == "avatar" {
|
||||
if !isImage {
|
||||
fail(w, http.StatusBadRequest, 20001, "头像必须是图片")
|
||||
return
|
||||
}
|
||||
contentType, extension, size = "image/jpeg", "-av1.jpg", int64(len(imageVariants.Display))
|
||||
} else if isImage {
|
||||
// Mark image uploads that have an immutable thumbnail sibling. The main
|
||||
// object remains byte-for-byte original for detail and preview screens.
|
||||
extension = "-im1" + originalExtension
|
||||
}
|
||||
|
||||
provider := a.configPlain(r.Context(), "storage.provider", "local")
|
||||
name := fmt.Sprintf("%d-%d%s", current(r).ID, time.Now().UnixNano(), extension)
|
||||
@@ -93,6 +143,7 @@ func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) {
|
||||
var storage mediaObjectStorage
|
||||
closeStorage := func() {}
|
||||
if provider == "local" {
|
||||
storage = localMediaStorage{directory: a.configPlain(r.Context(), "storage.local.directory", a.config.MediaDir)}
|
||||
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])
|
||||
@@ -117,6 +168,32 @@ func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, http.StatusServiceUnavailable, 50001, "文件存储公开地址过长")
|
||||
return
|
||||
}
|
||||
objects := []mediaUploadObject{{Key: objectKey, ContentType: contentType, Size: size, Body: file}}
|
||||
var thumbnailURL, originalURL string
|
||||
if isImage {
|
||||
mainExtension := filepath.Ext(objectKey)
|
||||
stem := strings.TrimSuffix(objectKey, mainExtension)
|
||||
urlStem := strings.TrimSuffix(publicURL, mainExtension)
|
||||
thumbnailURL, originalURL = urlStem+"-thumb.jpg", urlStem+"-original"+originalExtension
|
||||
if len(thumbnailURL) > 500 || len(stem+"-thumb.jpg") > 500 || (purpose == "avatar" && (len(originalURL) > 500 || len(stem+"-original"+originalExtension) > 500)) {
|
||||
fail(w, http.StatusServiceUnavailable, 50001, "文件存储公开地址过长")
|
||||
return
|
||||
}
|
||||
// Publish the primary URL only once its thumbnail is available.
|
||||
if purpose == "avatar" {
|
||||
objects = []mediaUploadObject{
|
||||
{Key: stem + "-original" + originalExtension, ContentType: originalType, Size: originalSize, Body: file},
|
||||
{Key: stem + "-thumb.jpg", ContentType: "image/jpeg", Size: int64(len(imageVariants.Thumbnail)), Body: bytes.NewReader(imageVariants.Thumbnail)},
|
||||
{Key: objectKey, ContentType: contentType, Size: size, Body: bytes.NewReader(imageVariants.Display)},
|
||||
}
|
||||
} else {
|
||||
originalURL = ""
|
||||
objects = []mediaUploadObject{
|
||||
{Key: stem + "-thumb.jpg", ContentType: "image/jpeg", Size: int64(len(imageVariants.Thumbnail)), Body: bytes.NewReader(imageVariants.Thumbnail)},
|
||||
{Key: objectKey, ContentType: originalType, Size: originalSize, Body: file},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -125,29 +202,12 @@ func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
mediaID, _ := result.LastInsertId()
|
||||
cleanupRecord := func() {
|
||||
_, _ = a.db.ExecContext(r.Context(), `DELETE FROM media_assets WHERE id=? AND status=0`, mediaID)
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second)
|
||||
defer cancel()
|
||||
_, _ = a.db.ExecContext(ctx, `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)
|
||||
}
|
||||
cleanupObjects, err := putMediaObjects(r.Context(), storage, objects)
|
||||
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)
|
||||
@@ -155,21 +215,19 @@ func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
cleanupObjects()
|
||||
cleanupRecord()
|
||||
fail(w, http.StatusInternalServerError, 50001, "完成媒体记录失败")
|
||||
return
|
||||
}
|
||||
reply(w, map[string]any{
|
||||
"id": mediaID,
|
||||
"name": name,
|
||||
"url": publicURL,
|
||||
"provider": provider,
|
||||
"objectKey": objectKey,
|
||||
"id": mediaID,
|
||||
"name": name,
|
||||
"url": publicURL,
|
||||
"provider": provider,
|
||||
"objectKey": objectKey,
|
||||
"thumbnailUrl": thumbnailURL,
|
||||
"originalUrl": originalURL,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user