package routes import ( "bufio" "fmt" "io" "log" "net/http" "net/url" "os" "regexp" "strconv" "strings" "time" "kvtube-go/services" "github.com/gin-gonic/gin" ) // getAllowedOrigins returns allowed CORS origins from environment variable or defaults func getAllowedOrigins() []string { originsEnv := os.Getenv("CORS_ALLOWED_ORIGINS") if originsEnv == "" { // Default: allow localhost for development return []string{ "http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:5011", "http://127.0.0.1:5011", } } origins := strings.Split(originsEnv, ",") for i := range origins { origins[i] = strings.TrimSpace(origins[i]) } return origins } // isAllowedOrigin checks if the given origin is in the allowed list func isAllowedOrigin(origin string, allowedOrigins []string) bool { for _, allowed := range allowedOrigins { if allowed == "*" || allowed == origin { return true } } return false } // isAllowedDomain checks if the URL belongs to allowed domains (YouTube/Google) func isAllowedDomain(targetURL string) error { parsedURL, err := url.Parse(targetURL) if err != nil { return err } // Allowed domains for video proxy allowedDomains := []string{ ".youtube.com", ".googlevideo.com", ".ytimg.com", ".google.com", ".gstatic.com", } host := strings.ToLower(parsedURL.Hostname()) // Check if host matches any allowed domain for _, domain := range allowedDomains { if strings.HasSuffix(host, domain) || host == strings.TrimPrefix(domain, ".") { return nil } } return fmt.Errorf("domain %s not allowed", host) } // validateSearchQuery ensures search query contains only safe characters func validateSearchQuery(query string) error { // Allow alphanumeric, spaces, hyphens, underscores, dots, commas, exclamation marks safePattern := regexp.MustCompile(`^[a-zA-Z0-9\s\-_.,!]+$`) if !safePattern.MatchString(query) { return fmt.Errorf("search query contains invalid characters") } if len(query) > 200 { return fmt.Errorf("search query too long") } return nil } // Global HTTP client with connection pooling and timeouts var httpClient = &http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second, }, } func SetupRouter() *gin.Engine { r := gin.Default() // CORS middleware - restrict to specific origins from environment variable allowedOrigins := getAllowedOrigins() r.Use(func(c *gin.Context) { origin := c.GetHeader("Origin") if origin != "" && isAllowedOrigin(origin, allowedOrigins) { c.Writer.Header().Set("Access-Control-Allow-Origin", origin) } c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) return } c.Next() }) r.GET("/api/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) // API Routes api := r.Group("/api") { api.GET("/search", handleSearch) api.GET("/trending", handleTrending) api.GET("/get_stream_info", handleGetStreamInfo) api.GET("/download", handleDownload) api.GET("/download-file", handleDownloadFile) api.GET("/transcript", handleTranscript) api.GET("/comments", handleComments) api.GET("/channel/videos", handleChannelVideos) api.GET("/channel/info", handleChannelInfo) api.GET("/related", handleRelatedVideos) api.GET("/formats", handleGetFormats) api.GET("/qualities", handleGetQualities) api.GET("/stream", handleGetStreamByQuality) // History routes api.POST("/history", handlePostHistory) api.GET("/history", handleGetHistory) api.GET("/suggestions", handleGetSuggestions) // Subscription routes api.POST("/subscribe", handleSubscribe) api.DELETE("/subscribe", handleUnsubscribe) api.GET("/subscribe", handleCheckSubscription) api.GET("/subscriptions", handleGetSubscriptions) } r.GET("/video_proxy", handleVideoProxy) return r } func handleSearch(c *gin.Context) { query := c.Query("q") if query == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter 'q' is required"}) return } // Validate search query for security if err := validateSearchQuery(query); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } limit := 20 if l := c.Query("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil { limit = parsed } } results, err := services.SearchVideos(query, limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search videos"}) return } c.JSON(http.StatusOK, results) } func handleTrending(c *gin.Context) { // Basic mock implementation for now c.JSON(http.StatusOK, gin.H{ "data": []gin.H{ { "id": "trending", "title": "Currently Trending", "icon": "fire", "videos": []gin.H{}, }, }, }) } func handleGetStreamInfo(c *gin.Context) { videoID := c.Query("v") if videoID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"}) return } info, qualities, audioURL, err := services.GetFullStreamData(videoID) if err != nil { log.Printf("GetFullStreamData Error: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video info"}) return } // Build quality options for frontend var qualityOptions []gin.H bestURL := info.StreamURL bestHeight := 0 for _, q := range qualities { proxyURL := "/video_proxy?url=" + url.QueryEscape(q.URL) audioProxyURL := "" if q.AudioURL != "" { audioProxyURL = "/video_proxy?url=" + url.QueryEscape(q.AudioURL) } qualityOptions = append(qualityOptions, gin.H{ "label": q.Label, "height": q.Height, "url": proxyURL, "audio_url": audioProxyURL, "is_hls": q.IsHLS, "has_audio": q.HasAudio, }) if q.Height > bestHeight { bestHeight = q.Height bestURL = q.URL } } // If we found qualities, use the best one streamURL := info.StreamURL if bestURL != "" { streamURL = bestURL } proxyURL := "/video_proxy?url=" + url.QueryEscape(streamURL) // Get audio URL for the response audioProxyURL := "" if audioURL != "" { audioProxyURL = "/video_proxy?url=" + url.QueryEscape(audioURL) } c.JSON(http.StatusOK, gin.H{ "original_url": info.StreamURL, "stream_url": proxyURL, "audio_url": audioProxyURL, "title": info.Title, "description": info.Description, "uploader": info.Uploader, "channel_id": info.ChannelID, "uploader_id": info.UploaderID, "view_count": info.ViewCount, "thumbnail": info.Thumbnail, "related": []interface{}{}, "subtitle_url": nil, "qualities": qualityOptions, "best_quality": bestHeight, }) } func handleDownload(c *gin.Context) { videoID := c.Query("v") if videoID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"}) return } formatID := c.Query("f") info, err := services.GetDownloadURL(videoID, formatID) if err != nil { log.Printf("GetDownloadURL Error: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get download URL"}) return } c.JSON(http.StatusOK, info) } func handleDownloadFile(c *gin.Context) { videoID := c.Query("v") if videoID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"}) return } formatID := c.Query("f") // Get the download URL from yt-dlp info, err := services.GetDownloadURL(videoID, formatID) if err != nil { log.Printf("GetDownloadURL Error: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get download URL"}) return } if info.URL == "" { c.JSON(http.StatusInternalServerError, gin.H{"error": "No download URL available"}) return } // Create request to the video URL req, err := http.NewRequest("GET", info.URL, nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"}) return } // Copy range header if present (for partial content/resumable downloads) if rangeHeader := c.GetHeader("Range"); rangeHeader != "" { req.Header.Set("Range", rangeHeader) } // Set appropriate headers for YouTube req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") req.Header.Set("Referer", "https://www.youtube.com/") req.Header.Set("Origin", "https://www.youtube.com") // Make the request resp, err := httpClient.Do(req) if err != nil { log.Printf("Failed to fetch video: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch video"}) return } defer resp.Body.Close() // Copy relevant headers from YouTube response to our response for key, values := range resp.Header { if key == "Content-Type" || key == "Content-Length" || key == "Content-Range" || key == "Accept-Ranges" || key == "Content-Disposition" { for _, value := range values { c.Header(key, value) } } } // Set content type based on extension contentType := resp.Header.Get("Content-Type") if contentType == "" { if info.Ext == "mp4" { contentType = "video/mp4" } else if info.Ext == "webm" { contentType = "video/webm" } else { contentType = "application/octet-stream" } } c.Header("Content-Type", contentType) // Set content disposition for download filename := fmt.Sprintf("%s.%s", info.Title, info.Ext) c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) // Copy status code c.Status(resp.StatusCode) // Stream the video io.Copy(c.Writer, resp.Body) } func handleGetFormats(c *gin.Context) { videoID := c.Query("v") if videoID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"}) return } formats, err := services.GetVideoFormats(videoID) if err != nil { log.Printf("GetVideoFormats Error: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video formats"}) return } c.JSON(http.StatusOK, formats) } func handleGetQualities(c *gin.Context) { videoID := c.Query("v") if videoID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"}) return } qualities, audioURL, err := services.GetVideoQualitiesWithAudio(videoID) if err != nil { log.Printf("GetVideoQualities Error: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video qualities"}) return } var result []gin.H for _, q := range qualities { proxyURL := "/video_proxy?url=" + url.QueryEscape(q.URL) audioProxyURL := "" if q.AudioURL != "" { audioProxyURL = "/video_proxy?url=" + url.QueryEscape(q.AudioURL) } result = append(result, gin.H{ "format_id": q.FormatID, "label": q.Label, "resolution": q.Resolution, "height": q.Height, "url": proxyURL, "audio_url": audioProxyURL, "is_hls": q.IsHLS, "vcodec": q.VCodec, "acodec": q.ACodec, "filesize": q.Filesize, "has_audio": q.HasAudio, }) } // Also return the best audio URL separately audioProxyURL := "" if audioURL != "" { audioProxyURL = "/video_proxy?url=" + url.QueryEscape(audioURL) } c.JSON(http.StatusOK, gin.H{ "success": true, "qualities": result, "audio_url": audioProxyURL, }) } func handleGetStreamByQuality(c *gin.Context) { videoID := c.Query("v") if videoID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"}) return } heightStr := c.Query("q") height := 0 if heightStr != "" { if parsed, err := strconv.Atoi(heightStr); err == nil { height = parsed } } qualities, audioURL, err := services.GetVideoQualitiesWithAudio(videoID) if err != nil { log.Printf("GetVideoQualities Error: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video qualities"}) return } if len(qualities) == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "No qualities available"}) return } var selected *services.QualityFormat for i := range qualities { if qualities[i].Height == height { selected = &qualities[i] break } } if selected == nil { selected = &qualities[0] } proxyURL := "/video_proxy?url=" + url.QueryEscape(selected.URL) audioProxyURL := "" if selected.AudioURL != "" { audioProxyURL = "/video_proxy?url=" + url.QueryEscape(selected.AudioURL) } else if audioURL != "" { audioProxyURL = "/video_proxy?url=" + url.QueryEscape(audioURL) } c.JSON(http.StatusOK, gin.H{ "success": true, "stream_url": proxyURL, "audio_url": audioProxyURL, "has_audio": selected.HasAudio, "quality": gin.H{ "label": selected.Label, "height": selected.Height, "is_hls": selected.IsHLS, }, }) } func handleRelatedVideos(c *gin.Context) { videoID := c.Query("v") title := c.Query("title") uploader := c.Query("uploader") if title == "" && videoID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID or Title required"}) return } limitStr := c.Query("limit") limit := 10 if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { limit = l } videos, err := services.GetRelatedVideos(title, uploader, limit) if err != nil { log.Printf("GetRelatedVideos Error: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get related videos"}) return } c.JSON(http.StatusOK, videos) } func handleTranscript(c *gin.Context) { c.JSON(http.StatusNotImplemented, gin.H{"error": "Not Implemented"}) } func handleComments(c *gin.Context) { videoID := c.Query("v") if videoID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"}) return } limit := 20 if l := c.Query("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { limit = parsed } } comments, err := services.GetComments(videoID, limit) if err != nil { log.Printf("GetComments Error: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get comments"}) return } c.JSON(http.StatusOK, comments) } func handleChannelInfo(c *gin.Context) { channelID := c.Query("id") if channelID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID 'id' is required"}) return } info, err := services.GetChannelInfo(channelID) if err != nil { log.Printf("GetChannelInfo Error: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get channel info"}) return } c.JSON(http.StatusOK, info) } func handleChannelVideos(c *gin.Context) { channelID := c.Query("id") if channelID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID 'id' is required"}) return } limitStr := c.Query("limit") limit := 30 if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { limit = l } videos, err := services.GetChannelVideos(channelID, limit) if err != nil { log.Printf("GetChannelVideos Error: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get channel videos", "details": err.Error()}) return } c.JSON(http.StatusOK, videos) } func handleVideoProxy(c *gin.Context) { targetURL := c.Query("url") if targetURL == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "No URL provided"}) return } // SSRF Protection: Validate target domain if err := isAllowedDomain(targetURL); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "URL domain not allowed"}) return } req, err := http.NewRequest("GET", targetURL, nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"}) return } // Forward standard headers req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") req.Header.Set("Referer", "https://www.youtube.com/") req.Header.Set("Origin", "https://www.youtube.com") if rangeHeader := c.GetHeader("Range"); rangeHeader != "" { req.Header.Set("Range", rangeHeader) } resp, err := httpClient.Do(req) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch video stream"}) return } defer resp.Body.Close() contentType := resp.Header.Get("Content-Type") baseURL := targetURL[:strings.LastIndex(targetURL, "/")] isManifest := strings.Contains(strings.ToLower(contentType), "mpegurl") || strings.HasSuffix(targetURL, ".m3u8") || strings.Contains(targetURL, ".m3u8") if isManifest && (resp.StatusCode == 200 || resp.StatusCode == 206) { // Rewrite M3U8 Manifest scanner := bufio.NewScanner(resp.Body) var newLines []string for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line != "" && !strings.HasPrefix(line, "#") { fullURL := line if !strings.HasPrefix(line, "http") { fullURL = baseURL + "/" + line } encodedURL := url.QueryEscape(fullURL) newLines = append(newLines, "/video_proxy?url="+encodedURL) } else { newLines = append(newLines, line) } } rewrittenContent := strings.Join(newLines, "\n") c.Data(resp.StatusCode, "application/vnd.apple.mpegurl", []byte(rewrittenContent)) return } // Stream binary video data for k, v := range resp.Header { logKey := strings.ToLower(k) if logKey != "content-encoding" && logKey != "transfer-encoding" && logKey != "connection" && !strings.HasPrefix(logKey, "access-control-") { c.Writer.Header()[k] = v } } c.Writer.WriteHeader(resp.StatusCode) io.Copy(c.Writer, resp.Body) } func handlePostHistory(c *gin.Context) { var body struct { VideoID string `json:"video_id"` Title string `json:"title"` Thumbnail string `json:"thumbnail"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) return } if body.VideoID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"}) return } err := services.AddToHistory(body.VideoID, body.Title, body.Thumbnail) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update history"}) return } c.JSON(http.StatusOK, gin.H{"status": "success"}) } func handleGetHistory(c *gin.Context) { limitStr := c.Query("limit") limit := 50 if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { limit = l } history, err := services.GetHistory(limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get history"}) return } // Make the API response shape match the VideoData shape the frontend expects // We'll reconstruct a basic VideoData-like array for the frontend var results []services.VideoData for _, h := range history { results = append(results, services.VideoData{ ID: h.ID, Title: h.Title, Thumbnail: h.Thumbnail, Uploader: "History", // Just a placeholder }) } c.JSON(http.StatusOK, results) } func handleGetSuggestions(c *gin.Context) { limitStr := c.Query("limit") limit := 20 if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { limit = l } suggestions, err := services.GetSuggestions(limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get suggestions"}) return } c.JSON(http.StatusOK, suggestions) } func handleSubscribe(c *gin.Context) { var body struct { ChannelID string `json:"channel_id"` ChannelName string `json:"channel_name"` ChannelAvatar string `json:"channel_avatar"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) return } if body.ChannelID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID is required"}) return } err := services.SubscribeChannel(body.ChannelID, body.ChannelName, body.ChannelAvatar) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to subscribe"}) return } c.JSON(http.StatusOK, gin.H{"status": "subscribed"}) } func handleUnsubscribe(c *gin.Context) { channelID := c.Query("channel_id") if channelID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID is required"}) return } err := services.UnsubscribeChannel(channelID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unsubscribe"}) return } c.JSON(http.StatusOK, gin.H{"status": "unsubscribed"}) } func handleCheckSubscription(c *gin.Context) { channelID := c.Query("channel_id") if channelID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID is required"}) return } subscribed, err := services.IsSubscribed(channelID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check subscription"}) return } c.JSON(http.StatusOK, gin.H{"subscribed": subscribed}) } func handleGetSubscriptions(c *gin.Context) { subs, err := services.GetSubscriptions() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get subscriptions"}) return } c.JSON(http.StatusOK, subs) }