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) } } }