Files
CanvasArchiver/internal/canvas/client.go
Joren 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

327 lines
8.8 KiB
Go

package canvas
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"git.directme.in/Joren/CanvasArchiver/internal/config"
"git.directme.in/Joren/CanvasArchiver/internal/models"
"git.directme.in/Joren/CanvasArchiver/internal/panopto"
"git.directme.in/Joren/CanvasArchiver/internal/utils"
)
type Client struct {
HTTPClient *http.Client
AccessToken string
CourseID string
CourseName string
FilesOnly bool
VideosOnly bool
ModuleNumbers bool
downloadedFiles map[string]bool
}
func NewClient(httpClient *http.Client, accessToken, courseID string, filesOnly, videosOnly, moduleNumbers bool) *Client {
return &Client{
HTTPClient: httpClient,
AccessToken: accessToken,
CourseID: courseID,
FilesOnly: filesOnly,
VideosOnly: videosOnly,
ModuleNumbers: moduleNumbers,
downloadedFiles: make(map[string]bool),
}
}
func (c *Client) GetCourseInfo() error {
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/courses/%s", config.BaseURL, c.CourseID), nil)
req.Header.Set("Authorization", "Bearer "+c.AccessToken)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var course models.Course
json.NewDecoder(resp.Body).Decode(&course)
c.CourseName = fmt.Sprintf("%s [%s]", course.Name, c.CourseID)
return nil
}
func (c *Client) GetEnrolledCourses() ([]models.Course, error) {
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/courses?enrollment_state=active&per_page=100", config.BaseURL), nil)
req.Header.Set("Authorization", "Bearer "+c.AccessToken)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var courses []models.Course
json.NewDecoder(resp.Body).Decode(&courses)
return courses, nil
}
func (c *Client) DownloadCourseFiles(root string) {
if c.VideosOnly {
fmt.Println("\n[*] Skipping regular course files (videos only mode)")
return
}
fmt.Println("\n[*] Fetching regular course files...")
fReq, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/courses/%s/folders?per_page=100", config.BaseURL, c.CourseID), nil)
fReq.Header.Set("Authorization", "Bearer "+c.AccessToken)
fResp, err := c.HTTPClient.Do(fReq)
if err != nil {
fmt.Printf("[!] Error fetching folders: %v\n", err)
return
}
var folders []models.Folder
json.NewDecoder(fResp.Body).Decode(&folders)
fResp.Body.Close()
folderMap := make(map[int]string)
for _, f := range folders {
folderMap[f.ID] = f.FullName
}
fileReq, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/courses/%s/files?per_page=100", config.BaseURL, c.CourseID), nil)
fileReq.Header.Set("Authorization", "Bearer "+c.AccessToken)
fileResp, err := c.HTTPClient.Do(fileReq)
if err != nil {
fmt.Printf("[!] Error fetching files: %v\n", err)
return
}
var files []models.File
json.NewDecoder(fileResp.Body).Decode(&files)
fileResp.Body.Close()
fileCount := 0
for _, file := range files {
if c.FilesOnly {
if c.downloadedFiles[file.DisplayName] {
continue
}
c.downloadedFiles[file.DisplayName] = true
}
rawFolderPath := folderMap[file.FolderID]
safeFolderPath := utils.SanitizePath(rawFolderPath)
subDir := root
if !c.FilesOnly {
if safeFolderPath != "" && strings.ToLower(safeFolderPath) != "course files" {
subDir = filepath.Join(root, "Course Files", safeFolderPath)
} else {
subDir = filepath.Join(root, "Course Files")
}
}
os.MkdirAll(subDir, 0o755)
path := filepath.Join(subDir, utils.Sanitize(file.DisplayName))
if _, err := os.Stat(path); err == nil {
continue
}
fmt.Printf(" -> Downloading: %s\n", file.DisplayName)
out, err := os.Create(path)
if err != nil {
fmt.Printf(" [!] Error creating file: %v\n", err)
continue
}
fData, err := c.HTTPClient.Get(file.URL)
if err != nil {
fmt.Printf(" [!] Error downloading file: %v\n", err)
out.Close()
continue
}
io.Copy(out, fData.Body)
fData.Body.Close()
out.Close()
fileCount++
}
fmt.Printf("[+] Downloaded %d regular files\n", fileCount)
}
func (c *Client) DownloadModules(courseRoot string) {
fmt.Println("\n[*] Fetching course modules...")
apiURL := fmt.Sprintf("%s/api/v1/courses/%s/modules?include[]=items&per_page=9999", config.BaseURL, c.CourseID)
req, _ := http.NewRequest("GET", apiURL, nil)
req.Header.Set("Authorization", "Bearer "+c.AccessToken)
resp, _ := c.HTTPClient.Do(req)
var modules []models.Module
json.NewDecoder(resp.Body).Decode(&modules)
resp.Body.Close()
for i, mod := range modules {
modName := mod.Name
if c.ModuleNumbers {
modName = fmt.Sprintf("[%d] %s", i+1, mod.Name)
}
// In videos-only mode everything goes flat into courseRoot.
// In files-only mode everything goes flat into courseRoot.
// Otherwise use the structured Modules/<name> path.
modBaseDir := courseRoot
if !c.FilesOnly && !c.VideosOnly {
modBaseDir = filepath.Join(courseRoot, "Modules", utils.Sanitize(modName))
}
os.MkdirAll(modBaseDir, 0o755)
if !c.FilesOnly && !c.VideosOnly {
fmt.Printf("\n[Module] %s\n", modName)
} else if c.VideosOnly {
fmt.Printf("\n[Module] %s (scanning for videos)\n", modName)
}
subHeaderStack := []string{}
lastIndent := 0
for _, item := range mod.Items {
// In videos-only mode always download to the flat courseRoot.
targetDir := modBaseDir
if len(subHeaderStack) > 0 && !c.FilesOnly && !c.VideosOnly {
targetDir = filepath.Join(modBaseDir, filepath.Join(subHeaderStack...))
}
os.MkdirAll(targetDir, 0o755)
switch item.Type {
case "SubHeader":
if c.FilesOnly || c.VideosOnly {
continue
}
currentIndent := item.Indent
if currentIndent <= lastIndent && len(subHeaderStack) > 0 {
levelsToKeep := currentIndent
if levelsToKeep < 0 {
levelsToKeep = 0
}
if levelsToKeep < len(subHeaderStack) {
subHeaderStack = subHeaderStack[:levelsToKeep]
}
}
subHeaderStack = append(subHeaderStack, utils.Sanitize(item.Title))
lastIndent = currentIndent
indent := strings.Repeat(" ", len(subHeaderStack))
fmt.Printf("%s--- %s ---\n", indent, item.Title)
case "File":
if c.VideosOnly {
continue
}
c.downloadModuleFile(item, targetDir)
case "ExternalTool":
if c.FilesOnly {
continue
}
indent := strings.Repeat(" ", len(subHeaderStack)+1)
fmt.Printf("%s- Found video tool: %s\n", indent, item.Title)
panopto.DownloadVideo(c.HTTPClient, c.AccessToken, c.CourseID, targetDir, item.URL, item.Title)
case "ExternalUrl":
if c.FilesOnly {
continue
}
if strings.Contains(item.ExternalURL, "panopto.eu") {
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.URL, item.ExternalURL, targetDir, item.Title)
}
case "Page":
if c.FilesOnly {
continue
}
c.searchPageForVideos(item, targetDir)
}
}
}
}
func (c *Client) downloadModuleFile(item models.ModuleItem, dir string) {
req, _ := http.NewRequest("GET", item.URL, nil)
req.Header.Set("Authorization", "Bearer "+c.AccessToken)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
var fileMeta struct {
URL string `json:"url"`
DisplayName string `json:"display_name"`
}
json.NewDecoder(resp.Body).Decode(&fileMeta)
if fileMeta.URL == "" {
return
}
if c.FilesOnly {
if c.downloadedFiles[fileMeta.DisplayName] {
return
}
c.downloadedFiles[fileMeta.DisplayName] = true
}
ext := filepath.Ext(fileMeta.DisplayName)
origBase := strings.TrimSuffix(fileMeta.DisplayName, ext)
fileName := fileMeta.DisplayName
if !c.FilesOnly && !strings.EqualFold(origBase, item.Title) && item.Title != "" {
fileName = fmt.Sprintf("%s (%s)%s", origBase, item.Title, ext)
}
path := filepath.Join(dir, utils.Sanitize(fileName))
if _, err := os.Stat(path); err == nil {
return
}
fmt.Printf(" -> File: %s\n", fileName)
out, _ := os.Create(path)
fData, _ := c.HTTPClient.Get(fileMeta.URL)
io.Copy(out, fData.Body)
fData.Body.Close()
out.Close()
}
func (c *Client) searchPageForVideos(item models.ModuleItem, dir string) {
req, _ := http.NewRequest("GET", item.URL, nil)
req.Header.Set("Authorization", "Bearer "+c.AccessToken)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
var page struct {
Body string `json:"body"`
}
json.NewDecoder(resp.Body).Decode(&page)
re := regexp.MustCompile(`https?://vub\.cloud\.panopto\.eu/Panopto/Pages/[^"']+id=([a-f0-9-]{36})`)
matches := re.FindAllString(page.Body, -1)
if len(matches) > 0 {
fmt.Printf(" - Scanning Page '%s' for embedded videos...\n", item.Title)
for _, videoURL := range matches {
panopto.DownloadVideo(c.HTTPClient, c.AccessToken, c.CourseID, dir, videoURL, item.Title)
}
}
}