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