This commit is contained in:
Your Name
2026-09-03 08:38:17 +08:00
parent 6cd4f1b1db
commit 842990b0e7
1853 changed files with 278406 additions and 361 deletions
+91 -33
View File
@@ -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,
})
}