diff --git a/internal/web_server/handlers/file_handler.go b/internal/web_server/handlers/file_handler.go index 249bd72..e6c5a6d 100644 --- a/internal/web_server/handlers/file_handler.go +++ b/internal/web_server/handlers/file_handler.go @@ -4,6 +4,7 @@ import ( dbgen "Yimaru-Backend/gen/db" "Yimaru-Backend/internal/domain" "bytes" + "fmt" "io" "net/http" "strconv" @@ -23,6 +24,13 @@ type uploadMediaRes struct { EmbedURL string `json:"embed_url,omitempty"` } +type uploadMediaByURLReq struct { + MediaType string `json:"media_type"` + SourceURL string `json:"source_url"` + Title string `json:"title"` + Description string `json:"description"` +} + // 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/..."). @@ -84,6 +92,24 @@ func (h *Handler) GetFileURL(c *fiber.Ctx) error { // @Router /api/v1/files/upload [post] func (h *Handler) UploadMedia(c *fiber.Ctx) error { mediaType := strings.ToLower(strings.TrimSpace(c.FormValue("media_type"))) + sourceURL := strings.TrimSpace(c.FormValue("source_url")) + title := strings.TrimSpace(c.FormValue("title")) + description := strings.TrimSpace(c.FormValue("description")) + + if strings.Contains(strings.ToLower(c.Get("Content-Type")), "application/json") { + var req uploadMediaByURLReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + mediaType = strings.ToLower(strings.TrimSpace(req.MediaType)) + sourceURL = strings.TrimSpace(req.SourceURL) + title = strings.TrimSpace(req.Title) + description = strings.TrimSpace(req.Description) + } + if mediaType == "" { mediaType = "file" } @@ -94,14 +120,6 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error { }) } - 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": @@ -111,13 +129,6 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error { 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", @@ -129,85 +140,73 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error { }) } - fh, err := fileHeader.Open() + var ( + fileName string + contentType string + data []byte + ) + if sourceURL != "" { + fetched, err := fetchMediaFromSourceURL(c.Context(), sourceURL, mediaType, maxSize) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Failed to fetch source URL", + Error: err.Error(), + }) + } + fileName = fetched.FileName + contentType = fetched.ContentType + data = fetched.Data + } else { + fileHeader, err := c.FormFile("file") + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "File is required", + Error: err.Error(), + }) + } + 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", + }) + } + + 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]) + 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...) + fileName = fileHeader.Filename + } + + var err error + contentType, err = normalizeAndValidateMediaContentType(mediaType, contentType, fileName) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to read file", + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid file type", 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, - } - // DetectContentType can return "application/octet-stream" for some files (often via clients - // like Postman). If that happens, fall back to extension-based detection. - if contentType == "application/octet-stream" { - filenameLower := strings.ToLower(fileHeader.Filename) - lastDot := strings.LastIndex(filenameLower, ".") - ext := "" - if lastDot != -1 && lastDot+1 < len(filenameLower) { - ext = filenameLower[lastDot+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 - } - } - 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 + title = 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{ @@ -234,7 +233,7 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error { uploadResult, err := h.minioSvc.Upload( c.Context(), mediaType, - fileHeader.Filename, + fileName, bytes.NewReader(data), int64(len(data)), contentType, @@ -261,6 +260,55 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error { }) } +func normalizeAndValidateMediaContentType(mediaType, contentType, fileName string) (string, error) { + contentType = strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) + if contentType == "" { + contentType = "application/octet-stream" + } + + switch mediaType { + case "image": + if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" { + return "", fmt.Errorf("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 contentType == "application/octet-stream" { + filenameLower := strings.ToLower(fileName) + lastDot := strings.LastIndex(filenameLower, ".") + ext := "" + if lastDot != -1 && lastDot+1 < len(filenameLower) { + ext = filenameLower[lastDot+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 + } + } + if !allowedAudio[contentType] { + return "", fmt.Errorf("only supported audio formats are allowed") + } + case "video": + if !strings.HasPrefix(contentType, "video/") { + return "", fmt.Errorf("only video files are allowed") + } + } + + return contentType, nil +} + // UploadAudio uploads an audio file to MinIO and returns the object key. // @Summary Upload an audio file // @Tags files diff --git a/internal/web_server/handlers/media_source_fetcher.go b/internal/web_server/handlers/media_source_fetcher.go new file mode 100644 index 0000000..6ed21bd --- /dev/null +++ b/internal/web_server/handlers/media_source_fetcher.go @@ -0,0 +1,190 @@ +package handlers + +import ( + vimeopkg "Yimaru-Backend/internal/pkgs/vimeo" + "context" + "encoding/json" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "path" + "sort" + "strings" + "time" +) + +type sourceFetchResult struct { + FileName string + ContentType string + Data []byte +} + +func normalizeSourceURL(raw string, mediaType string) (string, error) { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return "", fmt.Errorf("invalid source URL: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return "", fmt.Errorf("source URL must use http or https") + } + + host := strings.ToLower(u.Hostname()) + + if strings.Contains(host, "drive.google.com") { + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + // Convert /file/d/{id}/view links to a direct-download endpoint. + if len(parts) >= 3 && parts[0] == "file" && parts[1] == "d" { + fileID := parts[2] + return "https://drive.google.com/uc?export=download&id=" + fileID, nil + } + } + + if mediaType == "video" && (strings.Contains(host, "vimeo.com") || strings.Contains(host, "player.vimeo.com")) { + directURL, err := resolveVimeoDirectFileURL(u.String()) + if err != nil { + return "", err + } + if directURL != "" { + return directURL, nil + } + } + + return u.String(), nil +} + +func resolveVimeoDirectFileURL(vimeoURL string) (string, error) { + videoID := vimeopkg.ExtractVideoID(vimeoURL) + if videoID == "" { + return "", fmt.Errorf("failed to extract Vimeo video id from source URL") + } + + src, err := url.Parse(vimeoURL) + if err != nil { + return "", fmt.Errorf("invalid Vimeo source URL: %w", err) + } + + configURL := "https://player.vimeo.com/video/" + videoID + "/config" + if hash := src.Query().Get("h"); hash != "" { + configURL += "?h=" + url.QueryEscape(hash) + } + + req, err := http.NewRequest(http.MethodGet, configURL, nil) + if err != nil { + return "", fmt.Errorf("failed to build Vimeo config request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "Yimaru-Backend/1.0") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to fetch Vimeo config: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("Vimeo config fetch failed with status %d", resp.StatusCode) + } + + var payload struct { + Request struct { + Files struct { + Progressive []struct { + URL string `json:"url"` + Height int `json:"height"` + MimeType string `json:"mime"` + } `json:"progressive"` + } `json:"files"` + } `json:"request"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", fmt.Errorf("failed to decode Vimeo config payload: %w", err) + } + + if len(payload.Request.Files.Progressive) == 0 { + return "", fmt.Errorf("no downloadable Vimeo progressive stream found; provide a Vimeo URL with a valid '?h=' hash or a direct file URL") + } + + sort.Slice(payload.Request.Files.Progressive, func(i, j int) bool { + return payload.Request.Files.Progressive[i].Height > payload.Request.Files.Progressive[j].Height + }) + + return payload.Request.Files.Progressive[0].URL, nil +} + +func fetchMediaFromSourceURL(ctx context.Context, rawSourceURL string, mediaType string, maxSize int64) (*sourceFetchResult, error) { + sourceURL, err := normalizeSourceURL(rawSourceURL, mediaType) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create source request: %w", err) + } + req.Header.Set("User-Agent", "Yimaru-Backend/1.0") + req.Header.Set("Accept", "*/*") + + client := &http.Client{ + Timeout: 2 * time.Minute, + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch source URL: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, fmt.Errorf("source URL returned status %d", resp.StatusCode) + } + + limited := io.LimitReader(resp.Body, maxSize+1) + data, err := io.ReadAll(limited) + if err != nil { + return nil, fmt.Errorf("failed to read source content: %w", err) + } + if int64(len(data)) > maxSize { + return nil, fmt.Errorf("source file exceeds size limit for media type '%s'", mediaType) + } + if len(data) == 0 { + return nil, fmt.Errorf("source URL returned empty content") + } + + contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) + if contentType != "" { + contentType = strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) + } + if contentType == "" || contentType == "application/octet-stream" { + sampleLen := 512 + if len(data) < sampleLen { + sampleLen = len(data) + } + contentType = http.DetectContentType(data[:sampleLen]) + } + + fileName := "source-upload" + if cd := resp.Header.Get("Content-Disposition"); cd != "" { + if _, params, parseErr := mime.ParseMediaType(cd); parseErr == nil { + if fn := strings.TrimSpace(params["filename"]); fn != "" { + fileName = fn + } + } + } + if fileName == "source-upload" { + if parsedURL, parseErr := url.Parse(sourceURL); parseErr == nil { + base := path.Base(parsedURL.Path) + if base != "" && base != "." && base != "/" { + fileName = base + } + } + } + + return &sourceFetchResult{ + FileName: fileName, + ContentType: contentType, + Data: data, + }, nil +}