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

307 lines
9.0 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"
)
// 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,
})
}
// 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",
})
}
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,
})
}