diff --git a/internal/canvas/client.go b/internal/canvas/client.go index 09bad89..31c4bfd 100644 --- a/internal/canvas/client.go +++ b/internal/canvas/client.go @@ -240,7 +240,7 @@ func (c *Client) DownloadModules(courseRoot string) { indent := strings.Repeat(" ", len(subHeaderStack)+1) fmt.Printf("%s- Found direct video link: %s\n", indent, item.Title) - panopto.DownloadVideo(c.HTTPClient, c.AccessToken, c.CourseID, targetDir, item.ExternalURL, item.Title) + panopto.DownloadExternalPanoptoURL(c.HTTPClient, c.AccessToken, item.URL, item.ExternalURL, targetDir, item.Title) } case "Page": diff --git a/internal/panopto/downloader.go b/internal/panopto/downloader.go index f059785..4328b63 100644 --- a/internal/panopto/downloader.go +++ b/internal/panopto/downloader.go @@ -376,3 +376,120 @@ func DownloadMainRecordings(httpClient *http.Client, accessToken, courseID, root fmt.Println("[!] No main recordings available or handshake failed") } } + +// DownloadExternalPanoptoURL authenticates via the module_item_redirect path +// (exactly what the Canvas mobile app does for ExternalUrl items) and then +// runs yt-dlp against the given Panopto URL with the resulting cookies. +// +// moduleItemURL – item.URL (https://canvas.vub.be/api/v1/.../module_item_redirect/) +// panoptoURL – item.ExternalURL (https://vub.cloud.panopto.eu/Panopto/Pages/...) +func DownloadExternalPanoptoURL(httpClient *http.Client, accessToken, moduleItemURL, panoptoURL, modDir, title string) { + jar, _ := cookiejar.New(nil) + // Follow all redirects automatically so Login.aspx → CookieCheck.aspx sets cookies. + webClient := &http.Client{Jar: jar} + + // Step 1: exchange the module item URL for a Canvas web session URL. + returnTo := moduleItemURL + "?display=borderless" + bridgeReq, _ := http.NewRequest("GET", + config.BaseURL+"/login/session_token?return_to="+url.QueryEscape(returnTo), nil) + bridgeReq.Header.Set("Authorization", "Bearer "+accessToken) + bridgeReq.Header.Set("User-Agent", config.UserAgent) + bResp, err := httpClient.Do(bridgeReq) + if err != nil { + fmt.Printf(" [!] session_token request failed: %v\n", err) + return + } + var bridgeData struct { + SessionURL string `json:"session_url"` + } + json.NewDecoder(bResp.Body).Decode(&bridgeData) + bResp.Body.Close() + + if bridgeData.SessionURL == "" { + fmt.Printf(" [!] No session_url returned (skipping %s)\n", title) + return + } + + // Step 2: GET session URL → Canvas shows OAuth2 confirm page for Panopto. + formResp, err := webClient.Get(bridgeData.SessionURL) + if err != nil { + fmt.Printf(" [!] Failed to load OAuth confirm page: %v\n", err) + return + } + formHTMLBytes, _ := io.ReadAll(formResp.Body) + formResp.Body.Close() + formHTML := string(formHTMLBytes) + + if strings.Contains(formHTML, "U hebt geen toegang") || strings.Contains(formHTML, "You do not have access") { + fmt.Printf(" [!] Access denied. Skipping %s\n", title) + return + } + + // Step 3: POST /login/oauth2/accept → Canvas redirects to Panopto Login.aspx?code=... + // webClient follows all hops automatically, ending with Panopto setting cookies. + action := utils.ResolveAction(bridgeData.SessionURL, formHTML) + formData := utils.ExtractFormFields(formHTML) + + postReq, _ := http.NewRequest("POST", action, strings.NewReader(formData.Encode())) + postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + postReq.Header.Set("User-Agent", config.UserAgent) + postReq.Header.Set("Origin", config.BaseURL) + postReq.Header.Set("Referer", bridgeData.SessionURL) + postResp, err := webClient.Do(postReq) + if err != nil { + fmt.Printf(" [!] OAuth accept POST failed: %v\n", err) + return + } + io.ReadAll(postResp.Body) + postResp.Body.Close() + + // webClient.Jar now holds Panopto session cookies from the CookieCheck chain. + panoptoDomain, _ := url.Parse("https://vub.cloud.panopto.eu") + cookies := webClient.Jar.Cookies(panoptoDomain) + if len(cookies) == 0 { + fmt.Printf(" [!] No Panopto cookies after auth – falling back for: %s\n", title) + DownloadVideo(httpClient, accessToken, "", modDir, panoptoURL, title) + return + } + + cookieFile := filepath.Join(modDir, ".cookies_ext.txt") + cData := "# Netscape HTTP Cookie File\n" + for _, c := range cookies { + cData += fmt.Sprintf(".vub.cloud.panopto.eu\tTRUE\t/\tTRUE\t0\t%s\t%s\n", c.Name, c.Value) + } + os.WriteFile(cookieFile, []byte(cData), 0o644) + + fmt.Printf(" [*] Downloading: %s\n", title) + + normalizedURL := normalizePanoptoURL(panoptoURL) + isList := strings.Contains(normalizedURL, "List.aspx") + + ytCmd := getYoutubeDLCommand() + var args []string + if isList { + args = []string{ + "--cookies", cookieFile, + "--referer", config.BaseURL + "/", + "-P", modDir, + "-o", utils.Sanitize(title) + "/%(title)s.%(ext)s", + normalizedURL, + } + } else { + args = []string{ + "--no-playlist", + "--cookies", cookieFile, + "--referer", config.BaseURL + "/", + "-P", modDir, + "-o", utils.Sanitize(title) + ".%(ext)s", + normalizedURL, + } + } + + cmd := exec.Command(ytCmd, args...) + 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) +}