3 Commits

Author SHA1 Message Date
522a8b22f8 Downloader fixes with videos 2026-05-16 22:51:38 +02:00
ea9d4dc2dc fix: use item.HTMLURL (module_item_redirect) not item.URL for ExternalUrl auth
For ExternalUrl module items, the Canvas API returns url=null and
html_url=.../module_item_redirect/<id>. We were passing item.URL (empty)
causing the session_token call to return no session_url.
2026-05-16 22:37:45 +02:00
333e784ce9 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.
2026-05-16 22:33:22 +02:00

View File

@@ -376,3 +376,187 @@ func DownloadMainRecordings(httpClient *http.Client, accessToken, courseID, root
fmt.Println("[!] No main recordings available or handshake failed") 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) {
fmt.Printf(" [dbg] moduleItemURL: %s\n", moduleItemURL)
fmt.Printf(" [dbg] panoptoURL: %s\n", panoptoURL)
jar, _ := cookiejar.New(nil)
// Manual redirect following so we can track cross-domain hops correctly.
noRedirectClient := &http.Client{
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
// Step 1: exchange the module item URL for a Canvas web session URL.
returnTo := moduleItemURL + "?display=borderless"
sessionTokenURL := config.BaseURL + "/login/session_token?return_to=" + url.QueryEscape(returnTo)
fmt.Printf(" [dbg] session_token URL: %s\n", sessionTokenURL)
bridgeReq, _ := http.NewRequest("GET", sessionTokenURL, 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
}
rawBody, _ := io.ReadAll(bResp.Body)
bResp.Body.Close()
fmt.Printf(" [dbg] session_token response (%d): %s\n", bResp.StatusCode, string(rawBody))
var bridgeData struct {
SessionURL string `json:"session_url"`
}
json.Unmarshal(rawBody, &bridgeData)
if bridgeData.SessionURL == "" {
fmt.Printf(" [!] No session_url returned (skipping %s)\n", title)
return
}
fmt.Printf(" [dbg] session_url: %s\n", bridgeData.SessionURL)
// Step 2: GET session URL → Canvas shows OAuth2 confirm page for Panopto.
// We follow redirects manually so cross-domain hops (canvas→panopto) work correctly.
currentURL := bridgeData.SessionURL
var formHTML string
var formFinalURL string
for hop := 0; hop < 10; hop++ {
hopReq, _ := http.NewRequest("GET", currentURL, nil)
hopReq.Header.Set("User-Agent", config.UserAgent)
hopResp, err := noRedirectClient.Do(hopReq)
if err != nil {
fmt.Printf(" [!] Failed hop %d to %s: %v\n", hop, currentURL, err)
return
}
body, _ := io.ReadAll(hopResp.Body)
hopResp.Body.Close()
fmt.Printf(" [dbg] hop %d: status=%d url=%s body=%d\n", hop, hopResp.StatusCode, currentURL, len(body))
if hopResp.StatusCode == 301 || hopResp.StatusCode == 302 || hopResp.StatusCode == 303 {
loc, _ := hopResp.Location()
if loc == nil {
fmt.Printf(" [!] Redirect with no Location header\n")
return
}
currentURL = loc.String()
continue
}
formHTML = string(body)
formFinalURL = currentURL
break
}
fmt.Printf(" [dbg] OAuth page final URL: %s, length: %d\n", formFinalURL, len(formHTML))
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(formFinalURL, formHTML)
formData := utils.ExtractFormFields(formHTML)
fmt.Printf(" [dbg] POST action: %s\n", action)
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", formFinalURL)
postResp, err := noRedirectClient.Do(postReq)
if err != nil {
fmt.Printf(" [!] OAuth accept POST failed: %v\n", err)
return
}
postBody, _ := io.ReadAll(postResp.Body)
postResp.Body.Close()
fmt.Printf(" [dbg] POST response status: %d, body len: %d\n", postResp.StatusCode, len(postBody))
// Now follow the redirect chain from the POST (canvas → panopto Login.aspx → CookieCheck).
if postResp.StatusCode == 302 || postResp.StatusCode == 303 {
currentURL2 := ""
if loc, _ := postResp.Location(); loc != nil {
currentURL2 = loc.String()
}
for hop := 0; hop < 10 && currentURL2 != ""; hop++ {
hopReq, _ := http.NewRequest("GET", currentURL2, nil)
hopReq.Header.Set("User-Agent", config.UserAgent)
hopResp, err := noRedirectClient.Do(hopReq)
if err != nil {
fmt.Printf(" [dbg] post-redirect hop %d error: %v\n", hop, err)
break
}
body2, _ := io.ReadAll(hopResp.Body)
hopResp.Body.Close()
fmt.Printf(" [dbg] post-redirect hop %d: status=%d url=%s body=%d\n", hop, hopResp.StatusCode, currentURL2, len(body2))
if hopResp.StatusCode == 301 || hopResp.StatusCode == 302 || hopResp.StatusCode == 303 {
if loc, _ := hopResp.Location(); loc != nil {
currentURL2 = loc.String()
continue
}
}
break
}
}
// noRedirectClient.Jar now holds Panopto session cookies from the CookieCheck chain.
panoptoDomain, _ := url.Parse("https://vub.cloud.panopto.eu")
cookies := noRedirectClient.Jar.Cookies(panoptoDomain)
fmt.Printf(" [dbg] Panopto cookies: %d\n", len(cookies))
for _, c := range cookies {
fmt.Printf(" [dbg] cookie: %s=%s\n", c.Name, c.Value[:min(20, len(c.Value))])
}
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)
}