package panopto import ( "encoding/json" "fmt" "io" "net/http" "net/http/cookiejar" "net/url" "os" "os/exec" "path/filepath" "regexp" "strings" "git.directme.in/Joren/CanvasArchiver/internal/config" "git.directme.in/Joren/CanvasArchiver/internal/utils" ) func getYoutubeDLCommand() string { exePath, err := os.Executable() if err == nil { dir := filepath.Dir(exePath) localYtDlp := filepath.Join(dir, "yt-dlp.exe") if _, err := os.Stat(localYtDlp); err == nil { return localYtDlp } if _, err := os.Stat("yt-dlp.exe"); err == nil { abs, _ := filepath.Abs("yt-dlp.exe") return abs } } return "yt-dlp" } func DownloadVideo(httpClient *http.Client, accessToken, courseID, modDir, inputURL, title string) { jar, _ := cookiejar.New(nil) panoptoClient := &http.Client{ Jar: jar, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, } var launchURL string if strings.Contains(inputURL, "/api/v1/") { launchURL = inputURL } else if strings.Contains(inputURL, "panopto.eu") { launchURL = fmt.Sprintf("%s/api/v1/courses/%s/external_tools/sessionless_launch?url=%s", config.BaseURL, courseID, url.QueryEscape(inputURL)) } else { launchURL = fmt.Sprintf("%s/api/v1/courses/%s/external_tools/sessionless_launch?id=%s&launch_type=course_navigation", config.BaseURL, courseID, config.PanoptoID) } req, _ := http.NewRequest("GET", launchURL, nil) req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("User-Agent", config.UserAgent) resp, err := httpClient.Do(req) if err != nil { fmt.Printf(" [!] Failed to get launch URL: %v\n", err) return } var launchData struct { URL string `json:"url"` } json.NewDecoder(resp.Body).Decode(&launchData) resp.Body.Close() if launchData.URL == "" { return } bridgeReq, _ := http.NewRequest("GET", config.BaseURL+"/login/session_token?return_to="+url.QueryEscape(launchData.URL), nil) bridgeReq.Header.Set("Authorization", "Bearer "+accessToken) bridgeReq.Header.Set("User-Agent", config.UserAgent) bResp, _ := httpClient.Do(bridgeReq) var bridgeData struct { SessionURL string `json:"session_url"` } json.NewDecoder(bResp.Body).Decode(&bridgeData) bResp.Body.Close() formReq, _ := http.NewRequest("GET", bridgeData.SessionURL, nil) formReq.Header.Set("User-Agent", config.UserAgent) formResp, err := httpClient.Do(formReq) if err != nil { return } formHTML, _ := io.ReadAll(formResp.Body) formResp.Body.Close() action := utils.ResolveAction(bridgeData.SessionURL, string(formHTML)) formData := utils.ExtractFormFields(string(formHTML)) pReq, _ := http.NewRequest("POST", action, strings.NewReader(formData.Encode())) pReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") pReq.Header.Set("User-Agent", config.UserAgent) pReq.Header.Set("Origin", config.BaseURL) pReq.Header.Set("Referer", config.BaseURL+"/") pResp, err := panoptoClient.Do(pReq) if err != nil { fmt.Printf(" [!] Panopto POST failed: %v\n", err) return } pHtml, _ := io.ReadAll(pResp.Body) pResp.Body.Close() var finalURL string redirectRegex := regexp.MustCompile(`window\.location\.replace\('([^']+)'\)`) match := redirectRegex.FindStringSubmatch(string(pHtml)) if len(match) > 1 { finalURL, _ = url.QueryUnescape(strings.ReplaceAll(match[1], `\x`, "%")) } else { action2 := utils.ResolveAction(action, string(pHtml)) validData := utils.ExtractFormFields(string(pHtml)) validData.Set("__EVENTTARGET", "checkedLtiPostMessage") validData.Set("__EVENTARGUMENT", "") validData.Set("checkedLtiPostMessage", "true") validData.Set("ltiPostMessage", "") finalReq, _ := http.NewRequest("POST", action2, strings.NewReader(validData.Encode())) finalReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") finalReq.Header.Set("User-Agent", config.UserAgent) finalReq.Header.Set("Origin", "https://vub.cloud.panopto.eu") finalReq.Header.Set("Referer", action) finalResp, err := panoptoClient.Do(finalReq) if err == nil { finalHTMLBytes, _ := io.ReadAll(finalResp.Body) finalHTML := string(finalHTMLBytes) finalResp.Body.Close() if finalResp.StatusCode == 302 || finalResp.StatusCode == 303 { loc, _ := finalResp.Location() if loc != nil { finalURL = loc.String() } } else { finalMatch := redirectRegex.FindStringSubmatch(finalHTML) if len(finalMatch) > 1 { finalURL, _ = url.QueryUnescape(strings.ReplaceAll(finalMatch[1], `\x`, "%")) } } } } if finalURL != "" && !strings.Contains(finalURL, "NonFatalError") { cookieFile := filepath.Join(modDir, ".cookies_temp.txt") cData := "# Netscape HTTP Cookie File\n" panoptoDomain, _ := url.Parse("https://vub.cloud.panopto.eu") for _, cookie := range panoptoClient.Jar.Cookies(panoptoDomain) { cData += fmt.Sprintf(".vub.cloud.panopto.eu\tTRUE\t/\tTRUE\t0\t%s\t%s\n", cookie.Name, cookie.Value) } os.WriteFile(cookieFile, []byte(cData), 0o644) fmt.Printf(" [*] Downloading video: %s\n", title) ytCmd := getYoutubeDLCommand() cmd := exec.Command(ytCmd, "--no-playlist", "--cookies", cookieFile, "--referer", config.BaseURL+"/", "-P", modDir, "-o", utils.Sanitize(title)+".%(ext)s", finalURL) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { fmt.Printf(" [!] yt-dlp failed: %v\n", err) } os.Remove(cookieFile) } else { fmt.Printf(" [!] Handshake failed for: %s\n", title) } } func DownloadMainRecordings(httpClient *http.Client, accessToken, courseID, root string) { fmt.Println("\n[*] Checking for main Panopto recordings...") jar, _ := cookiejar.New(nil) mainClient := &http.Client{Jar: jar} req, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/courses/%s/external_tools/sessionless_launch?id=%s&launch_type=course_navigation", config.BaseURL, courseID, config.PanoptoID), nil) req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("User-Agent", config.UserAgent) resp, _ := httpClient.Do(req) var launchData struct { URL string `json:"url"` } json.NewDecoder(resp.Body).Decode(&launchData) resp.Body.Close() if launchData.URL == "" { fmt.Println("[!] No main Panopto recordings found") return } bridgeReq, _ := http.NewRequest("GET", config.BaseURL+"/login/session_token?return_to="+url.QueryEscape(launchData.URL), nil) bridgeReq.Header.Set("Authorization", "Bearer "+accessToken) bridgeReq.Header.Set("User-Agent", config.UserAgent) bResp, _ := httpClient.Do(bridgeReq) var bridgeData struct { SessionURL string `json:"session_url"` } json.NewDecoder(bResp.Body).Decode(&bridgeData) bResp.Body.Close() formResp, _ := mainClient.Get(bridgeData.SessionURL) formHTML, _ := io.ReadAll(formResp.Body) formResp.Body.Close() action := utils.ResolveAction(bridgeData.SessionURL, string(formHTML)) formData := utils.ExtractFormFields(string(formHTML)) pResp, _ := mainClient.PostForm(action, formData) pHtml, _ := io.ReadAll(pResp.Body) pResp.Body.Close() redirectRegex := regexp.MustCompile(`window\.location\.replace\('([^']+)'\)`) finalMatch := redirectRegex.FindStringSubmatch(string(pHtml)) var finalHTML string var finalURL string if len(finalMatch) > 1 { finalURL = finalMatch[1] } else { validData := utils.ExtractFormFields(string(pHtml)) validData.Set("__EVENTTARGET", "checkedLtiPostMessage") validData.Set("__EVENTARGUMENT", "") validData.Set("checkedLtiPostMessage", "true") validData.Set("ltiPostMessage", "") finalResp, _ := mainClient.PostForm(action, validData) finalHTMLBytes, _ := io.ReadAll(finalResp.Body) finalHTML = string(finalHTMLBytes) finalResp.Body.Close() finalMatch = redirectRegex.FindStringSubmatch(finalHTML) if len(finalMatch) > 1 { finalURL = finalMatch[1] } } if finalURL != "" { cleanURL := strings.ReplaceAll(finalURL, `\x`, "%") decodedURL, err := url.QueryUnescape(cleanURL) if err != nil { decodedURL = cleanURL } fmt.Printf("[+] Panopto URL Found: %s\n", decodedURL) cookieFile := filepath.Join(root, ".cookies_main.txt") cData := "# Netscape HTTP Cookie File\n" panoptoURL, _ := url.Parse("https://vub.cloud.panopto.eu") for _, cookie := range mainClient.Jar.Cookies(panoptoURL) { cData += fmt.Sprintf(".vub.cloud.panopto.eu\tTRUE\t/\tTRUE\t0\t%s\t%s\n", cookie.Name, cookie.Value) } os.WriteFile(cookieFile, []byte(cData), 0o644) recordingsDir := filepath.Join(root, "Recordings") os.MkdirAll(recordingsDir, 0o755) fmt.Println("[*] Starting yt-dlp download for main recordings...") ytCmd := getYoutubeDLCommand() cmd := exec.Command(ytCmd, "--cookies", cookieFile, "--referer", config.BaseURL+"/", "-P", recordingsDir, "-o", "%(playlist_title)s/%(title)s.%(ext)s", decodedURL, ) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Run() os.Remove(cookieFile) } else { fmt.Println("[!] No main recordings available or handshake failed") } }