From 2776d057cd926c87e617c001e9db636d78fd9191 Mon Sep 17 00:00:00 2001 From: Joren Date: Sat, 16 May 2026 22:53:11 +0200 Subject: [PATCH] fix: revert ExternalUrl auth to DownloadVideo, remove dead debug code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DownloadVideo already handles the Panopto Login.aspx canvas auth form correctly through its multi-step form submission logic. The real fix for ExternalUrl Panopto folder links was the URL normalization (List.aspx?folderID= → List.aspx#folderID="...") already in DownloadVideo. Remove DownloadExternalPanoptoURL (dead code) and all debug prints. Update README with -vo flag documentation. --- README.md | 12 ++- internal/panopto/downloader.go | 183 --------------------------------- 2 files changed, 10 insertions(+), 185 deletions(-) diff --git a/README.md b/README.md index 4a64b2c..566791b 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,16 @@ go build -o canvasarchiver ./cmd/canvasarchiver ./canvasarchiver ``` - Or for files-only mode: + Or for files-only mode (all files flat, no videos): ```bash ./canvasarchiver -fo ``` + Or for videos-only mode (all Panopto videos flat, no files): + ```bash + ./canvasarchiver -vo + ``` + 2. On first run, you'll be prompted to authenticate: - Visit the provided OAuth URL - Authorize the application @@ -38,11 +43,14 @@ go build -o canvasarchiver ./cmd/canvasarchiver - Module content (to `Modules/`) - Panopto recordings (to `Recordings/`) + In `-vo` mode, only videos are downloaded — all into the course root directory (no subdirectories). + ### Flags | Flag | Description | |------|-------------| -| `-fo` | Files only mode - download all files to a single directory without module structure | +| `-fo` | Files only — download all files flat into one directory; skips videos and module structure | +| `-vo` | Videos only — scan recordings and all module video items, download everything flat into one directory; skips regular files | | `-me` | Download all enrolled courses | | `-n` | Prefix modules with order numbers `[1]`, `[2]`, etc. | diff --git a/internal/panopto/downloader.go b/internal/panopto/downloader.go index 6f0f392..7ef90c2 100644 --- a/internal/panopto/downloader.go +++ b/internal/panopto/downloader.go @@ -377,186 +377,3 @@ func DownloadMainRecordings(httpClient *http.Client, accessToken, courseID, root } } -// 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) { - 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) -}