Yimaru-BackEnd/internal/web_server/handlers/file_handler.go

529 lines
16 KiB
Go

package handlers
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"bytes"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
)
type uploadMediaRes struct {
ObjectKey string `json:"object_key"`
URL string `json:"url"`
ContentType string `json:"content_type"`
MediaType string `json:"media_type"`
Provider string `json:"provider"`
VimeoID string `json:"vimeo_id,omitempty"`
EmbedURL string `json:"embed_url,omitempty"`
}
// resolveFileURL converts a stored file path to a usable URL.
// If the path starts with "minio://", it generates a presigned URL.
// Otherwise it returns the path as-is (e.g. "/static/...").
func (h *Handler) resolveFileURL(c *fiber.Ctx, storedPath string) string {
if h.minioSvc == nil || !strings.HasPrefix(storedPath, "minio://") {
return storedPath
}
objectKey := strings.TrimPrefix(storedPath, "minio://")
url, err := h.minioSvc.GetURL(c.Context(), objectKey, 1*time.Hour)
if err != nil {
return storedPath
}
return url
}
// GetFileURL resolves a MinIO object key to a presigned download URL.
// @Summary Get presigned URL for a file
// @Tags files
// @Param key query string true "MinIO object key (e.g. profile_pictures/uuid.jpg)"
// @Success 200 {object} domain.Response
// @Router /api/v1/files/url [get]
func (h *Handler) GetFileURL(c *fiber.Ctx) error {
key := strings.TrimSpace(c.Query("key"))
if key == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Missing 'key' query parameter",
})
}
if h.minioSvc == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{
Message: "File storage service is not available",
})
}
url, err := h.minioSvc.GetURL(c.Context(), key, 1*time.Hour)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to generate file URL",
Error: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "File URL generated",
Data: map[string]string{"url": url},
Success: true,
StatusCode: fiber.StatusOK,
})
}
// UploadMedia uploads an image/audio/video file and returns its URL and key.
// @Summary Upload media file
// @Tags files
// @Accept multipart/form-data
// @Param media_type formData string true "Media type: image|audio|video"
// @Param file formData file true "Media file"
// @Success 200 {object} domain.Response
// @Router /api/v1/files/upload [post]
func (h *Handler) UploadMedia(c *fiber.Ctx) error {
mediaType := strings.ToLower(strings.TrimSpace(c.FormValue("media_type")))
if mediaType == "" {
mediaType = "file"
}
if mediaType != "image" && mediaType != "audio" && mediaType != "video" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid media_type",
Error: "media_type must be one of: image, audio, video",
})
}
fileHeader, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "File is required",
Error: err.Error(),
})
}
maxSize := int64(100 * 1024 * 1024) // default 100MB
switch mediaType {
case "image":
maxSize = 10 * 1024 * 1024
case "audio":
maxSize = 50 * 1024 * 1024
case "video":
maxSize = 500 * 1024 * 1024
}
if fileHeader.Size > maxSize {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "File too large",
Error: "File exceeds size limit for selected media_type",
})
}
if mediaType == "video" && h.vimeoSvc == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{
Message: "Vimeo service is not available for video uploads",
})
}
if (mediaType == "image" || mediaType == "audio") && h.minioSvc == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{
Message: "MinIO service is not available for image/audio uploads",
})
}
fh, err := fileHeader.Open()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to read file",
Error: err.Error(),
})
}
defer fh.Close()
head := make([]byte, 512)
n, _ := fh.Read(head)
contentType := http.DetectContentType(head[:n])
switch mediaType {
case "image":
if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid file type",
Error: "Only jpg, png, and webp images are allowed",
})
}
case "audio":
allowedAudio := map[string]bool{
"audio/mpeg": true, "audio/wav": true, "audio/ogg": true, "audio/mp4": true,
"audio/aac": true, "audio/webm": true, "video/ogg": true, "video/webm": true,
"audio/x-wav": true, "audio/x-m4a": true, "audio/flac": true,
}
if !allowedAudio[contentType] {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid file type",
Error: "Only supported audio formats are allowed",
})
}
case "video":
if !strings.HasPrefix(contentType, "video/") {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid file type",
Error: "Only video files are allowed",
})
}
}
rest, err := io.ReadAll(fh)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to read file",
Error: err.Error(),
})
}
data := append(head[:n], rest...)
if mediaType == "video" {
title := strings.TrimSpace(c.FormValue("title"))
if title == "" {
title = fileHeader.Filename
}
description := strings.TrimSpace(c.FormValue("description"))
vimeoUpload, err := h.vimeoSvc.UploadVideoFile(c.Context(), title, description, bytes.NewReader(data), int64(len(data)))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to upload video to Vimeo",
Error: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Video uploaded successfully",
Data: uploadMediaRes{
URL: vimeoUpload.Link,
ContentType: contentType,
MediaType: mediaType,
Provider: "VIMEO",
VimeoID: vimeoUpload.VimeoID,
EmbedURL: "https://player.vimeo.com/video/" + vimeoUpload.VimeoID,
},
Success: true,
StatusCode: fiber.StatusOK,
})
}
uploadResult, err := h.minioSvc.Upload(
c.Context(),
mediaType,
fileHeader.Filename,
bytes.NewReader(data),
int64(len(data)),
contentType,
)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to upload file",
Error: err.Error(),
})
}
storedPath := "minio://" + uploadResult.ObjectKey
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "File uploaded successfully",
Data: uploadMediaRes{
ObjectKey: uploadResult.ObjectKey,
URL: h.resolveFileURL(c, storedPath),
ContentType: contentType,
MediaType: mediaType,
Provider: "MINIO",
},
Success: true,
StatusCode: fiber.StatusOK,
})
}
// UploadAudio uploads an audio file to MinIO and returns the object key.
// @Summary Upload an audio file
// @Tags files
// @Accept multipart/form-data
// @Param file formance file true "Audio file (mp3, wav, ogg, m4a, aac, webm)"
// @Success 200 {object} domain.Response
// @Router /api/v1/files/audio [post]
func (h *Handler) UploadAudio(c *fiber.Ctx) error {
if h.minioSvc == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{
Message: "File storage service is not available",
})
}
fileHeader, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Audio file is required",
Error: err.Error(),
})
}
const maxSize = 50 * 1024 * 1024 // 50 MB
if fileHeader.Size > maxSize {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "File too large",
Error: "Audio file must be <= 50MB",
})
}
fh, err := fileHeader.Open()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to read file",
Error: err.Error(),
})
}
defer fh.Close()
head := make([]byte, 512)
n, _ := fh.Read(head)
contentType := http.DetectContentType(head[:n])
allowedTypes := map[string]bool{
"audio/mpeg": true, // mp3
"audio/wav": true,
"audio/ogg": true,
"audio/mp4": true, // m4a
"audio/aac": true,
"audio/webm": true,
"video/ogg": true, // ogg sometimes detected as video/ogg
"video/webm": true, // webm audio sometimes detected as video/webm
"audio/x-wav": true,
"audio/x-m4a": true,
"audio/flac": true,
}
// DetectContentType may return "application/octet-stream" for some audio formats.
// In that case, fall back to extension-based detection.
if contentType == "application/octet-stream" {
ext := strings.ToLower(strings.TrimLeft(strings.ToLower(fileHeader.Filename[strings.LastIndex(fileHeader.Filename, "."):]), "."))
extMap := map[string]string{
"mp3": "audio/mpeg",
"wav": "audio/wav",
"ogg": "audio/ogg",
"m4a": "audio/mp4",
"aac": "audio/aac",
"webm": "audio/webm",
"flac": "audio/flac",
}
if ct, ok := extMap[ext]; ok {
contentType = ct
}
}
if !allowedTypes[contentType] {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid file type",
Error: "Only audio files are allowed (mp3, wav, ogg, m4a, aac, webm, flac)",
})
}
rest, err := io.ReadAll(fh)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to read file",
Error: err.Error(),
})
}
data := append(head[:n], rest...)
result, err := h.minioSvc.Upload(c.Context(), "audio", fileHeader.Filename, bytes.NewReader(data), int64(len(data)), contentType)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to upload audio file",
Error: err.Error(),
})
}
storedPath := "minio://" + result.ObjectKey
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Audio file uploaded successfully",
Data: map[string]string{
"object_key": result.ObjectKey,
"url": h.resolveFileURL(c, storedPath),
"content_type": contentType,
},
Success: true,
StatusCode: fiber.StatusOK,
})
}
// SubmitAudioAnswer allows a learner to upload an audio recording as their answer
// to an AUDIO-type question within a question set (practice).
// @Summary Submit audio answer for a question
// @Tags questions
// @Accept multipart/form-data
// @Param question_id formData int true "Question ID"
// @Param question_set_id formData int true "Question Set ID"
// @Param file formData file true "Audio recording"
// @Success 200 {object} domain.Response
// @Router /api/v1/questions/audio-answer [post]
func (h *Handler) SubmitAudioAnswer(c *fiber.Ctx) error {
if h.minioSvc == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{
Message: "File storage service is not available",
})
}
userID, ok := c.Locals("user_id").(int64)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Message: "Unauthorized",
})
}
// JSON mode: store reference to previously uploaded media key
if strings.Contains(strings.ToLower(c.Get("Content-Type")), "application/json") {
var req struct {
QuestionID int64 `json:"question_id"`
QuestionSetID int64 `json:"question_set_id"`
ObjectKey string `json:"object_key"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if req.QuestionID <= 0 || req.QuestionSetID <= 0 || strings.TrimSpace(req.ObjectKey) == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "question_id, question_set_id and object_key are required",
})
}
row, err := h.analyticsDB.CreateUserAudioResponse(c.Context(), dbgen.CreateUserAudioResponseParams{
UserID: userID,
QuestionID: req.QuestionID,
QuestionSetID: req.QuestionSetID,
AudioObjectKey: req.ObjectKey,
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to save audio answer",
Error: err.Error(),
})
}
audioURL := h.resolveFileURL(c, "minio://"+req.ObjectKey)
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Audio answer submitted successfully",
Data: map[string]interface{}{
"id": row.ID,
"question_id": row.QuestionID,
"question_set_id": row.QuestionSetID,
"audio_url": audioURL,
"created_at": row.CreatedAt,
},
Success: true,
StatusCode: fiber.StatusOK,
})
}
questionID, err := strconv.ParseInt(c.FormValue("question_id"), 10, 64)
if err != nil || questionID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question_id",
})
}
questionSetID, err := strconv.ParseInt(c.FormValue("question_set_id"), 10, 64)
if err != nil || questionSetID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question_set_id",
})
}
fileHeader, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Audio file is required",
Error: err.Error(),
})
}
const maxSize = 50 * 1024 * 1024 // 50 MB
if fileHeader.Size > maxSize {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "File too large",
Error: "Audio file must be <= 50MB",
})
}
fh, err := fileHeader.Open()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to read file",
Error: err.Error(),
})
}
defer fh.Close()
head := make([]byte, 512)
n, _ := fh.Read(head)
contentType := http.DetectContentType(head[:n])
// Fallback for audio formats that DetectContentType can't identify
if contentType == "application/octet-stream" {
dotIdx := strings.LastIndex(fileHeader.Filename, ".")
if dotIdx >= 0 {
ext := strings.ToLower(fileHeader.Filename[dotIdx+1:])
extMap := map[string]string{
"mp3": "audio/mpeg", "wav": "audio/wav", "ogg": "audio/ogg",
"m4a": "audio/mp4", "aac": "audio/aac", "webm": "audio/webm", "flac": "audio/flac",
}
if ct, ok := extMap[ext]; ok {
contentType = ct
}
}
}
rest, err := io.ReadAll(fh)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to read file",
Error: err.Error(),
})
}
data := append(head[:n], rest...)
uploadResult, err := h.minioSvc.Upload(c.Context(), "audio_answers", fileHeader.Filename, bytes.NewReader(data), int64(len(data)), contentType)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to upload audio answer",
Error: err.Error(),
})
}
row, err := h.analyticsDB.CreateUserAudioResponse(c.Context(), dbgen.CreateUserAudioResponseParams{
UserID: userID,
QuestionID: questionID,
QuestionSetID: questionSetID,
AudioObjectKey: uploadResult.ObjectKey,
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to save audio answer",
Error: err.Error(),
})
}
audioURL := h.resolveFileURL(c, "minio://"+uploadResult.ObjectKey)
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Audio answer submitted successfully",
Data: map[string]interface{}{
"id": row.ID,
"question_id": row.QuestionID,
"question_set_id": row.QuestionSetID,
"audio_url": audioURL,
"created_at": row.CreatedAt,
},
Success: true,
StatusCode: fiber.StatusOK,
})
}