From 333e784ce92277b039df5ae28b1eb2a20de84f96 Mon Sep 17 00:00:00 2001 From: Joren Date: Sat, 16 May 2026 22:33:22 +0200 Subject: [PATCH] fix: use module_item_redirect OAuth flow for ExternalUrl Panopto items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Canvas app authenticates ExternalUrl items via: GET session_token?return_to=?display=borderless → GET session_url → OAuth2 confirm → POST /login/oauth2/accept → Panopto Login.aspx?code= → CookieCheck.aspx (sets Panopto cookies) Our previous code used sessionless_launch (the course-level Panopto tool) for direct Panopto links, which gave wrong/incomplete Panopto cookies. Added DownloadExternalPanoptoURL() that replicates the exact app flow. Falls back to DownloadVideo if no Panopto cookies are obtained. Both List.aspx (folder playlists) and Viewer.aspx (single videos) are handled with the correct yt-dlp flags and output templates. --- internal/canvas/client.go | 2 +- internal/panopto/downloader.go | 117 +++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) 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) +}