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.
496 lines
15 KiB
Go
496 lines
15 KiB
Go
package panopto
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/http/cookiejar"
|
||
"net/url"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
|
||
"git.directme.in/Joren/CanvasArchiver/internal/config"
|
||
"git.directme.in/Joren/CanvasArchiver/internal/utils"
|
||
)
|
||
|
||
// normalizePanoptoURL converts the query-param form that Canvas stores
|
||
// (List.aspx?folderID=X) to the fragment form that yt-dlp's PanoptoList
|
||
// extractor understands (List.aspx#folderID="X"). Without this yt-dlp
|
||
// ignores the folder filter and downloads the entire Panopto instance.
|
||
func normalizePanoptoURL(rawURL string) string {
|
||
parsed, err := url.Parse(rawURL)
|
||
if err != nil {
|
||
return rawURL
|
||
}
|
||
if strings.Contains(parsed.Path, "List.aspx") {
|
||
folderID := parsed.Query().Get("folderID")
|
||
if folderID != "" {
|
||
// Strip query, set fragment: List.aspx#folderID="<id>"
|
||
parsed.RawQuery = ""
|
||
parsed.Fragment = fmt.Sprintf(`folderID="%s"`, folderID)
|
||
return parsed.String()
|
||
}
|
||
}
|
||
return rawURL
|
||
}
|
||
|
||
func getYoutubeDLCommand() string {
|
||
exePath, err := os.Executable()
|
||
if err == nil {
|
||
dir := filepath.Dir(exePath)
|
||
localYtDlp := filepath.Join(dir, "yt-dlp.exe")
|
||
if _, err := os.Stat(localYtDlp); err == nil {
|
||
return localYtDlp
|
||
}
|
||
|
||
if _, err := os.Stat("yt-dlp.exe"); err == nil {
|
||
abs, _ := filepath.Abs("yt-dlp.exe")
|
||
return abs
|
||
}
|
||
}
|
||
|
||
return "yt-dlp"
|
||
}
|
||
|
||
func DownloadVideo(httpClient *http.Client, accessToken, courseID, modDir, inputURL, title string) {
|
||
jar, _ := cookiejar.New(nil)
|
||
panoptoClient := &http.Client{
|
||
Jar: jar,
|
||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||
return http.ErrUseLastResponse
|
||
},
|
||
}
|
||
|
||
var launchURL string
|
||
isDirectLink := false
|
||
|
||
if strings.Contains(inputURL, "/api/v1/") {
|
||
launchURL = inputURL
|
||
} else {
|
||
|
||
isDirectLink = true
|
||
launchURL = fmt.Sprintf("%s/api/v1/courses/%s/external_tools/sessionless_launch?id=%s&launch_type=course_navigation",
|
||
config.BaseURL, courseID, config.PanoptoID)
|
||
}
|
||
|
||
req, _ := http.NewRequest("GET", launchURL, nil)
|
||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||
req.Header.Set("User-Agent", config.UserAgent)
|
||
resp, err := httpClient.Do(req)
|
||
if err != nil {
|
||
fmt.Printf(" [!] Failed to get launch URL: %v\n", err)
|
||
return
|
||
}
|
||
var launchData struct {
|
||
URL string `json:"url"`
|
||
}
|
||
json.NewDecoder(resp.Body).Decode(&launchData)
|
||
resp.Body.Close()
|
||
|
||
if launchData.URL == "" {
|
||
fmt.Printf(" [!] No launch URL found (Video skipped)\n")
|
||
return
|
||
}
|
||
|
||
bridgeReq, _ := http.NewRequest("GET",
|
||
config.BaseURL+"/login/session_token?return_to="+url.QueryEscape(launchData.URL), nil)
|
||
bridgeReq.Header.Set("Authorization", "Bearer "+accessToken)
|
||
bridgeReq.Header.Set("User-Agent", config.UserAgent)
|
||
bResp, _ := httpClient.Do(bridgeReq)
|
||
var bridgeData struct {
|
||
SessionURL string `json:"session_url"`
|
||
}
|
||
json.NewDecoder(bResp.Body).Decode(&bridgeData)
|
||
bResp.Body.Close()
|
||
|
||
formResp, err := httpClient.Get(bridgeData.SessionURL)
|
||
if err != nil {
|
||
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 by Panopto (U hebt geen toegang). Skipping.\n")
|
||
return
|
||
}
|
||
|
||
action := utils.ResolveAction(bridgeData.SessionURL, formHTML)
|
||
formData := utils.ExtractFormFields(formHTML)
|
||
|
||
pReq, _ := http.NewRequest("POST", action, strings.NewReader(formData.Encode()))
|
||
pReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
pReq.Header.Set("User-Agent", config.UserAgent)
|
||
pReq.Header.Set("Origin", config.BaseURL)
|
||
pReq.Header.Set("Referer", config.BaseURL+"/")
|
||
|
||
pResp, err := panoptoClient.Do(pReq)
|
||
if err != nil {
|
||
fmt.Printf(" [!] Panopto POST failed: %v\n", err)
|
||
return
|
||
}
|
||
pHtml, _ := io.ReadAll(pResp.Body)
|
||
pResp.Body.Close()
|
||
|
||
var finalURL string
|
||
redirectRegex := regexp.MustCompile(`window\.location\.replace\('([^']+)'\)`)
|
||
match := redirectRegex.FindStringSubmatch(string(pHtml))
|
||
|
||
if len(match) > 1 {
|
||
finalURL, _ = url.QueryUnescape(strings.ReplaceAll(match[1], `\x`, "%"))
|
||
} else {
|
||
action2 := utils.ResolveAction(action, string(pHtml))
|
||
validData := utils.ExtractFormFields(string(pHtml))
|
||
validData.Set("__EVENTTARGET", "checkedLtiPostMessage")
|
||
validData.Set("__EVENTARGUMENT", "")
|
||
validData.Set("checkedLtiPostMessage", "true")
|
||
validData.Set("ltiPostMessage", "")
|
||
|
||
finalReq, _ := http.NewRequest("POST", action2, strings.NewReader(validData.Encode()))
|
||
finalReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
finalReq.Header.Set("User-Agent", config.UserAgent)
|
||
finalReq.Header.Set("Origin", "https://vub.cloud.panopto.eu")
|
||
finalReq.Header.Set("Referer", action)
|
||
|
||
finalResp, err := panoptoClient.Do(finalReq)
|
||
if err == nil {
|
||
finalHTMLBytes, _ := io.ReadAll(finalResp.Body)
|
||
finalHTML := string(finalHTMLBytes)
|
||
finalResp.Body.Close()
|
||
|
||
if finalResp.StatusCode == 302 || finalResp.StatusCode == 303 {
|
||
loc, _ := finalResp.Location()
|
||
if loc != nil {
|
||
finalURL = loc.String()
|
||
}
|
||
} else {
|
||
finalMatch := redirectRegex.FindStringSubmatch(finalHTML)
|
||
if len(finalMatch) > 1 {
|
||
finalURL, _ = url.QueryUnescape(strings.ReplaceAll(finalMatch[1], `\x`, "%"))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// This is for making sure yt-dlp does not auto-start downloading all videos, when access to a hyperlink is denied
|
||
if finalURL != "" && !strings.Contains(finalURL, "NonFatalError") {
|
||
|
||
targetURL := finalURL
|
||
if isDirectLink {
|
||
targetURL = inputURL
|
||
|
||
checkReq, _ := http.NewRequest("GET", targetURL, nil)
|
||
checkReq.Header.Set("User-Agent", config.UserAgent)
|
||
|
||
checkResp, err := panoptoClient.Do(checkReq)
|
||
if err == nil {
|
||
checkResp.Body.Close()
|
||
|
||
if checkResp.StatusCode == http.StatusFound || checkResp.StatusCode == http.StatusSeeOther {
|
||
loc, _ := checkResp.Location()
|
||
if loc != nil && (strings.Contains(loc.String(), "Login.aspx") || strings.Contains(loc.String(), "Auth")) {
|
||
fmt.Printf(" [!] Video inaccessible (redirects to Login). Skipping to prevent mass download.\n")
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
cookieFile := filepath.Join(modDir, ".cookies_temp.txt")
|
||
cData := "# Netscape HTTP Cookie File\n"
|
||
panoptoDomain, _ := url.Parse("https://vub.cloud.panopto.eu")
|
||
for _, cookie := range panoptoClient.Jar.Cookies(panoptoDomain) {
|
||
cData += fmt.Sprintf(".vub.cloud.panopto.eu\tTRUE\t/\tTRUE\t0\t%s\t%s\n", cookie.Name, cookie.Value)
|
||
}
|
||
os.WriteFile(cookieFile, []byte(cData), 0o644)
|
||
|
||
fmt.Printf(" [*] Downloading video: %s\n", title)
|
||
|
||
ytCmd := getYoutubeDLCommand()
|
||
|
||
// Normalize folder URLs so yt-dlp scopes to the right folder.
|
||
normalizedURL := normalizePanoptoURL(targetURL)
|
||
|
||
// Folder/list URLs are intentional playlists; don't pass --no-playlist.
|
||
isList := strings.Contains(normalizedURL, "List.aspx")
|
||
var outputTpl string
|
||
var args []string
|
||
if isList {
|
||
outputTpl = utils.Sanitize(title) + "/%(title)s.%(ext)s"
|
||
args = []string{
|
||
"--cookies", cookieFile,
|
||
"--referer", config.BaseURL + "/",
|
||
"-P", modDir,
|
||
"-o", outputTpl,
|
||
normalizedURL,
|
||
}
|
||
} else {
|
||
outputTpl = utils.Sanitize(title) + ".%(ext)s"
|
||
args = []string{
|
||
"--no-playlist",
|
||
"--cookies", cookieFile,
|
||
"--referer", config.BaseURL + "/",
|
||
"-P", modDir,
|
||
"-o", outputTpl,
|
||
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)
|
||
} else {
|
||
fmt.Printf(" [!] Handshake failed for: %s\n", title)
|
||
}
|
||
}
|
||
|
||
func DownloadMainRecordings(httpClient *http.Client, accessToken, courseID, root string, videosOnly bool) {
|
||
fmt.Println("\n[*] Checking for main Panopto recordings...")
|
||
|
||
jar, _ := cookiejar.New(nil)
|
||
mainClient := &http.Client{Jar: jar}
|
||
|
||
req, _ := http.NewRequest("GET",
|
||
fmt.Sprintf("%s/api/v1/courses/%s/external_tools/sessionless_launch?id=%s&launch_type=course_navigation",
|
||
config.BaseURL, courseID, config.PanoptoID), nil)
|
||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||
req.Header.Set("User-Agent", config.UserAgent)
|
||
resp, _ := httpClient.Do(req)
|
||
var launchData struct {
|
||
URL string `json:"url"`
|
||
}
|
||
json.NewDecoder(resp.Body).Decode(&launchData)
|
||
resp.Body.Close()
|
||
|
||
if launchData.URL == "" {
|
||
fmt.Println("[!] No main Panopto recordings found")
|
||
return
|
||
}
|
||
|
||
bridgeReq, _ := http.NewRequest("GET",
|
||
config.BaseURL+"/login/session_token?return_to="+url.QueryEscape(launchData.URL), nil)
|
||
bridgeReq.Header.Set("Authorization", "Bearer "+accessToken)
|
||
bridgeReq.Header.Set("User-Agent", config.UserAgent)
|
||
bResp, _ := httpClient.Do(bridgeReq)
|
||
var bridgeData struct {
|
||
SessionURL string `json:"session_url"`
|
||
}
|
||
json.NewDecoder(bResp.Body).Decode(&bridgeData)
|
||
bResp.Body.Close()
|
||
|
||
formResp, _ := mainClient.Get(bridgeData.SessionURL)
|
||
formHTML, _ := io.ReadAll(formResp.Body)
|
||
formResp.Body.Close()
|
||
|
||
action := utils.ResolveAction(bridgeData.SessionURL, string(formHTML))
|
||
formData := utils.ExtractFormFields(string(formHTML))
|
||
|
||
pResp, _ := mainClient.PostForm(action, formData)
|
||
pHtml, _ := io.ReadAll(pResp.Body)
|
||
pResp.Body.Close()
|
||
|
||
redirectRegex := regexp.MustCompile(`window\.location\.replace\('([^']+)'\)`)
|
||
finalMatch := redirectRegex.FindStringSubmatch(string(pHtml))
|
||
|
||
var finalHTML string
|
||
var finalURL string
|
||
|
||
if len(finalMatch) > 1 {
|
||
finalURL = finalMatch[1]
|
||
} else {
|
||
validData := utils.ExtractFormFields(string(pHtml))
|
||
validData.Set("__EVENTTARGET", "checkedLtiPostMessage")
|
||
validData.Set("__EVENTARGUMENT", "")
|
||
validData.Set("checkedLtiPostMessage", "true")
|
||
validData.Set("ltiPostMessage", "")
|
||
|
||
finalResp, _ := mainClient.PostForm(action, validData)
|
||
finalHTMLBytes, _ := io.ReadAll(finalResp.Body)
|
||
finalHTML = string(finalHTMLBytes)
|
||
finalResp.Body.Close()
|
||
|
||
finalMatch = redirectRegex.FindStringSubmatch(finalHTML)
|
||
if len(finalMatch) > 1 {
|
||
finalURL = finalMatch[1]
|
||
}
|
||
}
|
||
|
||
if finalURL != "" {
|
||
cleanURL := strings.ReplaceAll(finalURL, `\x`, "%")
|
||
decodedURL, err := url.QueryUnescape(cleanURL)
|
||
if err != nil {
|
||
decodedURL = cleanURL
|
||
}
|
||
|
||
fmt.Printf("[+] Panopto URL Found: %s\n", decodedURL)
|
||
|
||
cookieFile := filepath.Join(root, ".cookies_main.txt")
|
||
cData := "# Netscape HTTP Cookie File\n"
|
||
panoptoURL, _ := url.Parse("https://vub.cloud.panopto.eu")
|
||
for _, cookie := range mainClient.Jar.Cookies(panoptoURL) {
|
||
cData += fmt.Sprintf(".vub.cloud.panopto.eu\tTRUE\t/\tTRUE\t0\t%s\t%s\n", cookie.Name, cookie.Value)
|
||
}
|
||
os.WriteFile(cookieFile, []byte(cData), 0o644)
|
||
|
||
var downloadDir string
|
||
var outputTemplate string
|
||
|
||
if videosOnly {
|
||
// Flat: all videos go directly into course root
|
||
downloadDir = root
|
||
outputTemplate = "%(title)s.%(ext)s"
|
||
} else {
|
||
downloadDir = filepath.Join(root, "Recordings")
|
||
os.MkdirAll(downloadDir, 0o755)
|
||
outputTemplate = "%(playlist_title)s/%(title)s.%(ext)s"
|
||
}
|
||
|
||
fmt.Println("[*] Starting yt-dlp download for main recordings...")
|
||
|
||
ytCmd := getYoutubeDLCommand()
|
||
|
||
cmd := exec.Command(ytCmd,
|
||
"--cookies", cookieFile,
|
||
"--referer", config.BaseURL+"/",
|
||
"-P", downloadDir,
|
||
"-o", outputTemplate,
|
||
decodedURL,
|
||
)
|
||
cmd.Stdout = os.Stdout
|
||
cmd.Stderr = os.Stderr
|
||
cmd.Run()
|
||
|
||
os.Remove(cookieFile)
|
||
} else {
|
||
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)
|
||
}
|