fix: use module_item_redirect OAuth flow for ExternalUrl Panopto items

The Canvas app authenticates ExternalUrl items via:
  GET session_token?return_to=<module_item_redirect>?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.
This commit is contained in:
2026-05-16 22:33:22 +02:00
parent 43392a4132
commit 333e784ce9
2 changed files with 118 additions and 1 deletions

View File

@@ -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":

View File

@@ -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/<id>)
// 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)
}