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, }) }