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