From ea91e2a16e3936724e8fe14f4d3dfad95ebbc14a Mon Sep 17 00:00:00 2001 From: Joren Date: Fri, 13 Feb 2026 18:48:45 +0100 Subject: [PATCH] first commit --- .gitignore | 1 + Makefile | 32 ++++ README.md | 59 +++++++ cmd/canvasarchiver/main.go | 44 +++++ go.mod | 3 + internal/auth/auth.go | 94 +++++++++++ internal/canvas/client.go | 239 ++++++++++++++++++++++++++ internal/config/config.go | 11 ++ internal/models/models.go | 39 +++++ internal/panopto/downloader.go | 295 +++++++++++++++++++++++++++++++++ internal/utils/utils.go | 60 +++++++ 11 files changed, 877 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/canvasarchiver/main.go create mode 100644 go.mod create mode 100644 internal/auth/auth.go create mode 100644 internal/canvas/client.go create mode 100644 internal/config/config.go create mode 100644 internal/models/models.go create mode 100644 internal/panopto/downloader.go create mode 100644 internal/utils/utils.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83f6e39 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +credentials.json diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3930877 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +.PHONY: build clean run fmt vet + +BINARY=canvasarchiver + +build: + go build -o $(BINARY) ./cmd/canvasarchiver + +run: build + ./$(BINARY) + +clean: + go clean + rm -f $(BINARY) + rm -f credentials.json + +fmt: + go fmt ./... + +vet: + go vet ./... + +check: fmt vet + +deps: + go mod download + go mod tidy + +build-all: + GOOS=linux GOARCH=amd64 go build -o $(BINARY)-linux-amd64 ./cmd/canvasarchiver + GOOS=darwin GOARCH=amd64 go build -o $(BINARY)-darwin-amd64 ./cmd/canvasarchiver + GOOS=darwin GOARCH=arm64 go build -o $(BINARY)-darwin-arm64 ./cmd/canvasarchiver + GOOS=windows GOARCH=amd64 go build -o $(BINARY)-windows-amd64.exe ./cmd/canvasarchiver diff --git a/README.md b/README.md new file mode 100644 index 0000000..d494a95 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Canvas Archiver + +A command-line tool to archive Canvas LMS course content, including files, modules, and Panopto video recordings. + +## Prerequisites + +- Go 1.21 or higher +- [yt-dlp](https://github.com/yt-dlp/yt-dlp) installed and in PATH + +## Installation + +```bash +git clone git.directme.in/Joren/CanvasArchiver +cd CanvasArchiver +go build -o canvasarchiver ./cmd/canvasarchiver +``` + +## Usage +1. Run the archiver: + ```bash + ./canvasarchiver + ``` + +2. On first run, you'll be prompted to authenticate: + - Visit the provided OAuth URL + - Authorize the application + - Copy the authorization code back to the terminal + +3. Enter your Course ID when prompted + +4. The tool will download: + - Regular course files (to `Course Files/`) + - Module content (to `Modules/`) + - Panopto recordings (to `Recordings/`) +``` + +## Configuration + +The following constants can be modified in `internal/config/config.go`: + +- `BaseURL`: Canvas instance URL +- `ClientID`: OAuth client ID +- `ClientSecret`: OAuth client secret +- `PanoptoID`: Panopto external tool ID + +## Authentication + +Credentials are stored in `credentials.json` after the first successful login. The refresh token is automatically used for subsequent runs. + +## Notes + +- Files are organized to match Canvas structure +- SubHeaders in modules create nested folder structures +- Videos are downloaded with their original titles +- Existing files are skipped to avoid re-downloading + +## License + +This project is for educational purposes. Ensure you have permission to download course content. diff --git a/cmd/canvasarchiver/main.go b/cmd/canvasarchiver/main.go new file mode 100644 index 0000000..9eea08a --- /dev/null +++ b/cmd/canvasarchiver/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "fmt" + "net/http" + "os" + + "git.directme.in/Joren/CanvasArchiver/internal/auth" + "git.directme.in/Joren/CanvasArchiver/internal/canvas" + "git.directme.in/Joren/CanvasArchiver/internal/panopto" + "git.directme.in/Joren/CanvasArchiver/internal/utils" +) + +func main() { + httpClient := &http.Client{} + + authenticator := auth.NewAuthenticator(httpClient) + accessToken, err := authenticator.GetAccessToken() + if err != nil { + fmt.Printf("Authentication failed: %v\n", err) + return + } + + var courseID string + fmt.Print("Enter Course ID: ") + fmt.Scanln(&courseID) + + canvasClient := canvas.NewClient(httpClient, accessToken, courseID) + + if err := canvasClient.GetCourseInfo(); err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + courseRoot := utils.Sanitize(canvasClient.CourseName) + fmt.Printf("[+] Target Course: %s\n", canvasClient.CourseName) + os.MkdirAll(courseRoot, 0o755) + + canvasClient.DownloadCourseFiles(courseRoot) + + canvasClient.DownloadModules(courseRoot) + + panopto.DownloadMainRecordings(httpClient, accessToken, courseID, courseRoot) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bb87cf9 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.directme.in/Joren/CanvasArchiver + +go 1.21 diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..fbf5fc7 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,94 @@ +package auth + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + + "git.directme.in/Joren/CanvasArchiver/internal/config" + "git.directme.in/Joren/CanvasArchiver/internal/models" +) + +type Authenticator struct { + HTTPClient *http.Client +} + +func NewAuthenticator(client *http.Client) *Authenticator { + return &Authenticator{ + HTTPClient: client, + } +} + +func (a *Authenticator) GetAccessToken() (string, error) { + creds, err := LoadCredentials() + if err != nil { + + fmt.Println("--- Initial Canvas Login Required ---") + fmt.Printf("Visit: %s/login/oauth2/auth?client_id=%s&response_type=code&redirect_uri=%s\n", + config.BaseURL, config.ClientID, url.QueryEscape(config.RedirectURI)) + fmt.Print("Enter Code: ") + var code string + fmt.Scanln(&code) + + tr, err := a.doTokenRequest(url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {config.ClientID}, + "client_secret": {config.ClientSecret}, + "redirect_uri": {config.RedirectURI}, + "code": {code}, + }) + if err != nil { + return "", err + } + + SaveCredentials(&models.Credentials{RefreshToken: tr.RefreshToken}) + fmt.Println("[+] Login successful.") + return tr.AccessToken, nil + } + + fmt.Println("[*] Reusing saved refresh token...") + tr, err := a.doTokenRequest(url.Values{ + "grant_type": {"refresh_token"}, + "client_id": {config.ClientID}, + "client_secret": {config.ClientSecret}, + "refresh_token": {creds.RefreshToken}, + }) + if err != nil { + return "", err + } + fmt.Println("[+] Session refreshed.") + return tr.AccessToken, nil +} + +func (a *Authenticator) doTokenRequest(v url.Values) (*models.TokenResponse, error) { + resp, err := a.HTTPClient.PostForm(config.BaseURL+"/login/oauth2/token", v) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("token request failed: %d", resp.StatusCode) + } + + var tr models.TokenResponse + json.NewDecoder(resp.Body).Decode(&tr) + return &tr, nil +} + +func SaveCredentials(creds *models.Credentials) { + data, _ := json.MarshalIndent(creds, "", " ") + os.WriteFile(config.CredsFile, data, 0o644) +} + +func LoadCredentials() (*models.Credentials, error) { + data, err := os.ReadFile(config.CredsFile) + if err != nil { + return nil, err + } + var creds models.Credentials + json.Unmarshal(data, &creds) + return &creds, nil +} diff --git a/internal/canvas/client.go b/internal/canvas/client.go new file mode 100644 index 0000000..b1fcdb4 --- /dev/null +++ b/internal/canvas/client.go @@ -0,0 +1,239 @@ +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 +} + +func NewClient(httpClient *http.Client, accessToken, courseID string) *Client { + return &Client{ + HTTPClient: httpClient, + AccessToken: accessToken, + CourseID: courseID, + } +} + +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) DownloadCourseFiles(root string) { + 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 { + + rawFolderPath := folderMap[file.FolderID] + safeFolderPath := utils.SanitizePath(rawFolderPath) + + subDir := filepath.Join(root, "Course Files", safeFolderPath) + 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 _, mod := range modules { + modBaseDir := filepath.Join(courseRoot, "Modules", utils.Sanitize(mod.Name)) + os.MkdirAll(modBaseDir, 0o755) + fmt.Printf("\n[Module] %s\n", mod.Name) + + subHeaderStack := []string{} + lastIndent := 0 + + for _, item := range mod.Items { + + targetDir := modBaseDir + if len(subHeaderStack) > 0 { + targetDir = filepath.Join(modBaseDir, filepath.Join(subHeaderStack...)) + } + os.MkdirAll(targetDir, 0o755) + + switch item.Type { + case "SubHeader": + 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": + c.downloadModuleFile(item, targetDir) + + case "ExternalTool": + 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 "Page": + 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 + } + + ext := filepath.Ext(fileMeta.DisplayName) + origBase := strings.TrimSuffix(fileMeta.DisplayName, ext) + + fileName := fileMeta.DisplayName + if !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) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b91afa6 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,11 @@ +package config + +const ( + BaseURL = "https://canvas.vub.be" + ClientID = "170000000000044" + ClientSecret = "3sxR3NtgXRfT9KdpWGAFQygq6O9RzLN021h2lAzhHUZEeSQ5XGV41Ddi5iutwW6f" + RedirectURI = "urn:ietf:wg:oauth:2.0:oob" + CredsFile = "credentials.json" + PanoptoID = "15" + UserAgent = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36" +) diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..b91a430 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,39 @@ +package models + +type Credentials struct { + RefreshToken string `json:"refresh_token"` +} + +type TokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +type Course struct { + Name string `json:"name"` +} + +type Folder struct { + ID int `json:"id"` + FullName string `json:"full_name"` +} + +type File struct { + DisplayName string `json:"display_name"` + URL string `json:"url"` + FolderID int `json:"folder_id"` +} + +type Module struct { + Name string `json:"name"` + Items []ModuleItem `json:"items"` +} + +type ModuleItem struct { + Title string `json:"title"` + Type string `json:"type"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + ContentID int `json:"content_id"` + Indent int `json:"indent"` +} diff --git a/internal/panopto/downloader.go b/internal/panopto/downloader.go new file mode 100644 index 0000000..bb3fa23 --- /dev/null +++ b/internal/panopto/downloader.go @@ -0,0 +1,295 @@ +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" +) + +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 + if strings.Contains(inputURL, "/api/v1/") { + launchURL = inputURL + } else if strings.Contains(inputURL, "panopto.eu") { + launchURL = fmt.Sprintf("%s/api/v1/courses/%s/external_tools/sessionless_launch?url=%s", + config.BaseURL, courseID, url.QueryEscape(inputURL)) + } else { + 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 == "" { + 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() + + formReq, _ := http.NewRequest("GET", bridgeData.SessionURL, nil) + formReq.Header.Set("User-Agent", config.UserAgent) + formResp, err := httpClient.Do(formReq) + if err != nil { + return + } + formHTML, _ := io.ReadAll(formResp.Body) + formResp.Body.Close() + + action := utils.ResolveAction(bridgeData.SessionURL, string(formHTML)) + formData := utils.ExtractFormFields(string(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`, "%")) + } + } + } + } + + if finalURL != "" && !strings.Contains(finalURL, "NonFatalError") { + 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() + + cmd := exec.Command(ytCmd, + "--no-playlist", + "--cookies", cookieFile, + "--referer", config.BaseURL+"/", + "-P", modDir, + "-o", utils.Sanitize(title)+".%(ext)s", + finalURL) + 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) { + 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) + + recordingsDir := filepath.Join(root, "Recordings") + os.MkdirAll(recordingsDir, 0o755) + + fmt.Println("[*] Starting yt-dlp download for main recordings...") + + ytCmd := getYoutubeDLCommand() + + cmd := exec.Command(ytCmd, + "--cookies", cookieFile, + "--referer", config.BaseURL+"/", + "-P", recordingsDir, + "-o", "%(playlist_title)s/%(title)s.%(ext)s", + decodedURL, + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Run() + + os.Remove(cookieFile) + } else { + fmt.Println("[!] No main recordings available or handshake failed") + } +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..c175ef9 --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,60 @@ +package utils + +import ( + "net/url" + "path/filepath" + "regexp" + "strings" +) + +func Sanitize(s string) string { + return regexp.MustCompile(`[<>:"/\\|?*]`).ReplaceAllString(s, "_") +} + +func SanitizePath(path string) string { + parts := strings.Split(path, "/") + cleanParts := make([]string, len(parts)) + for i, part := range parts { + cleanParts[i] = Sanitize(part) + } + + return filepath.Join(cleanParts...) +} + +func ResolveAction(baseURL, html string) string { + match := regexp.MustCompile(`(?i)action="([^"]+)"`).FindStringSubmatch(html) + if len(match) < 2 { + return baseURL + } + u, _ := url.Parse(baseURL) + ref, _ := url.Parse(match[1]) + return u.ResolveReference(ref).String() +} + +func ExtractFormFields(html string) url.Values { + fields := url.Values{} + + reInput := regexp.MustCompile(`(?si)]*>`) + matches := reInput.FindAllString(html, -1) + + for _, tag := range matches { + nameMatch := regexp.MustCompile(`(?si)name=["']([^"']+)["']`).FindStringSubmatch(tag) + if len(nameMatch) < 2 { + continue + } + name := nameMatch[1] + + val := "" + valMatch := regexp.MustCompile(`(?si)value=["']([^"']*)["']`).FindStringSubmatch(tag) + if len(valMatch) > 1 { + val = valMatch[1] + + val = strings.ReplaceAll(val, """, "\"") + val = strings.ReplaceAll(val, "&", "&") + val = strings.ReplaceAll(val, "'", "'") + } + + fields.Set(name, val) + } + return fields +}