sojorn/go-backend/internal/handlers/media_handler.go
2026-02-15 00:33:24 -06:00

245 lines
6.5 KiB
Go

package handlers
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
)
// MediaHandler uploads media to Cloudflare R2. If s3Client is provided, it uses
// the S3-compatible API. Otherwise, it falls back to Cloudflare R2 HTTP API
// using account ID + API token from the config.
type MediaHandler struct {
s3Client *s3.Client
useS3 bool
accountID string
apiToken string
bucket string
videoBucket string
publicDomain string
videoDomain string
}
func NewMediaHandler(s3Client *s3.Client, accountID string, apiToken string, bucket string, videoBucket string, publicDomain string, videoDomain string) *MediaHandler {
return &MediaHandler{
s3Client: s3Client,
useS3: s3Client != nil,
accountID: accountID,
apiToken: apiToken,
bucket: bucket,
videoBucket: videoBucket,
publicDomain: publicDomain,
videoDomain: videoDomain,
}
}
func (h *MediaHandler) Upload(c *gin.Context) {
fileHeader, err := c.FormFile("media")
if err != nil {
fileHeader, err = c.FormFile("image")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No media file found"})
return
}
}
mediaType := c.PostForm("type")
if mediaType == "" {
if strings.HasPrefix(fileHeader.Header.Get("Content-Type"), "video/") {
mediaType = "video"
} else {
mediaType = "image"
}
}
ext := filepath.Ext(fileHeader.Filename)
if ext == "" {
if mediaType == "video" {
ext = ".mp4"
} else {
ext = ".jpg"
}
}
contentType := fileHeader.Header.Get("Content-Type")
userID := c.GetString("user_id")
if userID == "" {
userID = "anon"
}
objectKey := fmt.Sprintf("uploads/%s/%s%s", userID, uuid.New().String(), ext)
targetBucket := h.bucket
targetDomain := h.publicDomain
if mediaType == "video" {
if h.videoBucket != "" {
targetBucket = h.videoBucket
}
if h.videoDomain != "" {
targetDomain = h.videoDomain
}
}
// Read the uploaded file into memory
src, err := fileHeader.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read uploaded file"})
return
}
rawBytes, err := io.ReadAll(src)
src.Close()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read uploaded file"})
return
}
// Strip metadata (EXIF/GPS from images, all metadata from videos)
cleanBytes, stripErr := stripMetadata(rawBytes, mediaType, ext)
if stripErr != nil {
log.Warn().Err(stripErr).Str("type", mediaType).Msg("metadata strip failed, uploading original")
cleanBytes = rawBytes
}
var publicURL string
reader := bytes.NewReader(cleanBytes)
if h.useS3 {
publicURL, err = h.putObjectS3(c, reader, int64(len(cleanBytes)), contentType, targetBucket, objectKey, targetDomain)
} else {
publicURL, err = h.putObjectR2API(c, cleanBytes, contentType, targetBucket, objectKey, targetDomain)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to upload media: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{
"url": publicURL,
"publicUrl": publicURL,
"signedUrl": publicURL,
"fileName": objectKey,
"file_name": objectKey,
"fileSize": len(cleanBytes),
"file_size": len(cleanBytes),
"type": mediaType,
})
}
// stripMetadata uses ffmpeg to remove all metadata (EXIF, GPS, camera info)
// from uploaded media before it goes to R2. Returns the cleaned bytes.
func stripMetadata(raw []byte, mediaType string, ext string) ([]byte, error) {
if _, err := exec.LookPath("ffmpeg"); err != nil {
return nil, fmt.Errorf("ffmpeg not found: %w", err)
}
tmpDir, err := os.MkdirTemp("", "sojorn-media-*")
if err != nil {
return nil, err
}
defer os.RemoveAll(tmpDir)
inPath := filepath.Join(tmpDir, "input"+ext)
outPath := filepath.Join(tmpDir, "output"+ext)
if err := os.WriteFile(inPath, raw, 0644); err != nil {
return nil, err
}
// Run ffmpeg: strip all metadata
var args []string
if mediaType == "video" {
// Copy streams without re-encoding, just drop metadata
args = []string{"-i", inPath, "-map_metadata", "-1", "-c", "copy", "-y", outPath}
} else {
// For images: re-encode to strip EXIF (ffmpeg decodes + encodes, drops metadata)
args = []string{"-i", inPath, "-map_metadata", "-1", "-y", outPath}
}
cmd := exec.Command("ffmpeg", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
log.Warn().Str("stderr", stderr.String()).Msg("ffmpeg metadata strip stderr")
return nil, fmt.Errorf("ffmpeg failed: %w", err)
}
cleanBytes, err := os.ReadFile(outPath)
if err != nil {
return nil, err
}
log.Info().
Str("type", mediaType).
Int("original_size", len(raw)).
Int("stripped_size", len(cleanBytes)).
Msg("metadata stripped from upload")
return cleanBytes, nil
}
func (h *MediaHandler) putObjectS3(c *gin.Context, body io.ReadSeeker, contentLength int64, contentType string, bucket string, key string, publicDomain string) (string, error) {
ctx := c.Request.Context()
_, err := h.s3Client.PutObject(ctx, &s3.PutObjectInput{
Bucket: &bucket,
Key: &key,
Body: body,
ContentType: &contentType,
})
if err != nil {
return "", err
}
if publicDomain != "" {
return fmt.Sprintf("https://%s/%s", publicDomain, key), nil
}
// Fallback to path (relative); AssetService can sign it later.
return key, nil
}
func (h *MediaHandler) putObjectR2API(c *gin.Context, fileBytes []byte, contentType string, bucket string, key string, publicDomain string) (string, error) {
if h.accountID == "" || h.apiToken == "" {
return "", fmt.Errorf("R2 API credentials missing")
}
endpoint := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/r2/buckets/%s/objects/%s",
h.accountID, bucket, key)
req, err := http.NewRequestWithContext(c.Request.Context(), "PUT", endpoint, bytes.NewReader(fileBytes))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+h.apiToken)
req.Header.Set("Content-Type", contentType)
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("R2 upload failed (%d): %s", resp.StatusCode, string(body))
}
if publicDomain != "" {
return fmt.Sprintf("https://%s/%s", publicDomain, key), nil
}
return fmt.Sprintf("https://%s.r2.cloudflarestorage.com/%s/%s", h.accountID, bucket, key), nil
}