package app import ( "bytes" "context" "errors" "fmt" "io" "log" "net/http" "path/filepath" "regexp" "strings" "time" ) const maxUploadBytes int64 = 16 << 20 var mediaNamePattern = regexp.MustCompile(`^[0-9]+-[0-9]+(?:-av1(?:-thumb|-original)?|-im1(?:-thumb)?)?\.(?:gif|jpe?g|png|webp|mp3|wav|amr|m4a)$`) // Android's recorder can emit an MP3 stream without an ID3 tag. Go's generic // sniffer identifies only the ID3 form, so validate the first MPEG audio frame // header as a fallback instead of rejecting a valid phone recording as binary. func isMPEGAudioFrameHeader(header []byte) bool { if len(header) < 4 || header[0] != 0xff || header[1]&0xe0 != 0xe0 { return false } version := (header[1] >> 3) & 0x03 layer := (header[1] >> 1) & 0x03 bitrateIndex := (header[2] >> 4) & 0x0f sampleRateIndex := (header[2] >> 2) & 0x03 return version != 0x01 && layer != 0 && bitrateIndex != 0 && bitrateIndex != 0x0f && sampleRateIndex != 0x03 } func detectUploadContentType(header []byte) string { contentType := http.DetectContentType(header) if isMPEGAudioFrameHeader(header) { return "audio/mpeg" } return contentType } 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 // 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 } defer r.MultipartForm.RemoveAll() purpose := r.FormValue("purpose") if purpose != "" && purpose != "avatar" && purpose != "voice" { fail(w, http.StatusBadRequest, 20001, "上传用途无效") 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 := detectUploadContentType(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 } originalType, originalExtension, originalSize := contentType, extension, size isImage := strings.HasPrefix(contentType, "image/") if purpose == "voice" && isImage { fail(w, http.StatusBadRequest, 20001, "语音文件格式无效") return } 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) 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" { 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]) 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 } 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 { fail(w, http.StatusInternalServerError, 50001, "创建媒体记录失败") return } mediaID, _ := result.LastInsertId() cleanupRecord := func() { 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) } 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) 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 { cleanupObjects() cleanupRecord() fail(w, http.StatusInternalServerError, 50001, "完成媒体记录失败") return } reply(w, map[string]any{ "id": mediaID, "name": name, "url": publicURL, "provider": provider, "objectKey": objectKey, "thumbnailUrl": thumbnailURL, "originalUrl": originalURL, }) } 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))) }