diff --git a/internal/canvas/client.go b/internal/canvas/client.go index 4c7a9c8..09bad89 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.DownloadExternalPanoptoURL(c.HTTPClient, c.AccessToken, item.HTMLURL, item.ExternalURL, targetDir, item.Title) + panopto.DownloadVideo(c.HTTPClient, c.AccessToken, c.CourseID, targetDir, item.ExternalURL, item.Title) } case "Page": diff --git a/internal/panopto/downloader.go b/internal/panopto/downloader.go index 4328b63..6f0f392 100644 --- a/internal/panopto/downloader.go +++ b/internal/panopto/downloader.go @@ -384,14 +384,23 @@ func DownloadMainRecordings(httpClient *http.Client, accessToken, courseID, root // 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) { + fmt.Printf(" [dbg] moduleItemURL: %s\n", moduleItemURL) + fmt.Printf(" [dbg] panoptoURL: %s\n", panoptoURL) + jar, _ := cookiejar.New(nil) - // Follow all redirects automatically so Login.aspx → CookieCheck.aspx sets cookies. - webClient := &http.Client{Jar: jar} + // 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" - bridgeReq, _ := http.NewRequest("GET", - config.BaseURL+"/login/session_token?return_to="+url.QueryEscape(returnTo), nil) + 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) @@ -399,26 +408,51 @@ func DownloadExternalPanoptoURL(httpClient *http.Client, accessToken, moduleItem 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.NewDecoder(bResp.Body).Decode(&bridgeData) - bResp.Body.Close() + 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. - formResp, err := webClient.Get(bridgeData.SessionURL) - if err != nil { - fmt.Printf(" [!] Failed to load OAuth confirm page: %v\n", err) - return + // 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 } - formHTMLBytes, _ := io.ReadAll(formResp.Body) - formResp.Body.Close() - formHTML := string(formHTMLBytes) + 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) @@ -427,25 +461,58 @@ func DownloadExternalPanoptoURL(httpClient *http.Client, accessToken, moduleItem // 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) + 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", bridgeData.SessionURL) - postResp, err := webClient.Do(postReq) + postReq.Header.Set("Referer", formFinalURL) + postResp, err := noRedirectClient.Do(postReq) if err != nil { fmt.Printf(" [!] OAuth accept POST failed: %v\n", err) return } - io.ReadAll(postResp.Body) + postBody, _ := io.ReadAll(postResp.Body) postResp.Body.Close() + fmt.Printf(" [dbg] POST response status: %d, body len: %d\n", postResp.StatusCode, len(postBody)) - // webClient.Jar now holds Panopto session cookies from the CookieCheck chain. + // 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 := webClient.Jar.Cookies(panoptoDomain) + 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)