From 666369714b8559d8895f52b1ae61f65b31ebc279 Mon Sep 17 00:00:00 2001 From: Joren Date: Fri, 19 Jun 2026 21:12:43 +0200 Subject: [PATCH 1/2] feat: add --api flag for Canvas API token authentication - Add APIToken field to Credentials struct with omitempty JSON tag - Implement GetAPIToken() method with persistent token storage - Add SaveRefreshToken() helper to preserve API tokens during OAuth - Implement --api CLI flag to use API token instead of OAuth - Validate empty refresh tokens and skip refresh if invalid - Update README with API token usage documentation --- README.md | 12 ++++++-- cmd/canvasarchiver/main.go | 9 +++++- internal/auth/auth.go | 59 ++++++++++++++++++++++++++++++++++---- internal/models/models.go | 3 +- 4 files changed, 74 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 566791b..d522862 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,14 @@ go build -o canvasarchiver ./cmd/canvasarchiver ./canvasarchiver -fo ``` - Or for videos-only mode (all Panopto videos flat, no files): + Or for videos-only mode (all Panopto videos flat, no files): + ```bash + ./canvasarchiver -vo + ``` + + Or to use a manually generated Canvas API token: ```bash - ./canvasarchiver -vo + ./canvasarchiver --api ``` 2. On first run, you'll be prompted to authenticate: @@ -53,6 +58,7 @@ go build -o canvasarchiver ./cmd/canvasarchiver | `-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. | +| `--api` | Use a manually generated Canvas API token instead of OAuth | ## Configuration @@ -68,6 +74,8 @@ The following constants can be modified in `internal/config/config.go`: Credentials are stored in `credentials.json` after the first successful login. The refresh token is automatically used for subsequent runs. +With `--api`, enter a manually generated Canvas API token on first use. It is saved to `credentials.json` and reused on later `--api` runs. + ## Notes - Files are organized to match Canvas structure diff --git a/cmd/canvasarchiver/main.go b/cmd/canvasarchiver/main.go index f0dd336..eed401c 100644 --- a/cmd/canvasarchiver/main.go +++ b/cmd/canvasarchiver/main.go @@ -17,12 +17,19 @@ func main() { videosOnly := flag.Bool("vo", false, "Videos only mode - download only Panopto videos to a single directory") me := flag.Bool("me", false, "Download all enrolled courses") moduleNumbers := flag.Bool("n", false, "Prefix modules with order numbers [1], [2], etc.") + apiToken := flag.Bool("api", false, "Use a manually generated Canvas API token instead of OAuth") flag.Parse() httpClient := &http.Client{} authenticator := auth.NewAuthenticator(httpClient) - accessToken, err := authenticator.GetAccessToken() + var accessToken string + var err error + if *apiToken { + accessToken, err = authenticator.GetAPIToken() + } else { + accessToken, err = authenticator.GetAccessToken() + } if err != nil { fmt.Printf("Authentication failed: %v\n", err) return diff --git a/internal/auth/auth.go b/internal/auth/auth.go index a6fe8b8..9451ebc 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -32,6 +32,9 @@ func (a *Authenticator) GetAccessToken() (string, error) { if err != nil { return a.login() } + if strings.TrimSpace(creds.RefreshToken) == "" { + return a.login() + } fmt.Println("[*] Reusing saved refresh token...") tr, err := a.doTokenRequest(url.Values{ @@ -49,6 +52,36 @@ func (a *Authenticator) GetAccessToken() (string, error) { return tr.AccessToken, nil } +func (a *Authenticator) GetAPIToken() (string, error) { + creds, err := LoadCredentials() + if err == nil && strings.TrimSpace(creds.APIToken) != "" { + fmt.Println("[*] Reusing saved API token...") + return strings.TrimSpace(creds.APIToken), nil + } + if err != nil { + creds = &models.Credentials{} + } + + fmt.Print("Enter Canvas API token: ") + token, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil && err != io.EOF { + return "", err + } + + token = strings.TrimSpace(token) + if token == "" { + return "", fmt.Errorf("empty API token") + } + + creds.APIToken = token + if err := SaveCredentials(creds); err != nil { + return "", err + } + + fmt.Println("[+] API token saved.") + return token, nil +} + func (a *Authenticator) login() (string, error) { codeVerifier, codeChallenge, err := generatePKCEPair() if err != nil { @@ -80,7 +113,9 @@ func (a *Authenticator) login() (string, error) { return "", err } - SaveCredentials(&models.Credentials{RefreshToken: tr.RefreshToken}) + if err := SaveRefreshToken(tr.RefreshToken); err != nil { + return "", err + } fmt.Println("[+] Login successful.") return tr.AccessToken, nil } @@ -162,9 +197,21 @@ func extractCode(input string) (string, error) { return input, nil } -func SaveCredentials(creds *models.Credentials) { - data, _ := json.MarshalIndent(creds, "", " ") - os.WriteFile(config.CredsFile, data, 0o644) +func SaveCredentials(creds *models.Credentials) error { + data, err := json.MarshalIndent(creds, "", " ") + if err != nil { + return err + } + return os.WriteFile(config.CredsFile, data, 0o644) +} + +func SaveRefreshToken(refreshToken string) error { + creds, err := LoadCredentials() + if err != nil { + creds = &models.Credentials{} + } + creds.RefreshToken = refreshToken + return SaveCredentials(creds) } func LoadCredentials() (*models.Credentials, error) { @@ -173,6 +220,8 @@ func LoadCredentials() (*models.Credentials, error) { return nil, err } var creds models.Credentials - json.Unmarshal(data, &creds) + if err := json.Unmarshal(data, &creds); err != nil { + return nil, err + } return &creds, nil } diff --git a/internal/models/models.go b/internal/models/models.go index 14fdadd..40643ec 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -1,7 +1,8 @@ package models type Credentials struct { - RefreshToken string `json:"refresh_token"` + RefreshToken string `json:"refresh_token,omitempty"` + APIToken string `json:"api_token,omitempty"` } type TokenResponse struct { -- 2.49.1 From 07fade16d38df926d649411a6d164d2f6c1e0c66 Mon Sep 17 00:00:00 2001 From: Joren Date: Fri, 19 Jun 2026 21:17:00 +0200 Subject: [PATCH 2/2] feat: interactive auth method selection with API token validation - Add GetToken() as unified auth entry point with method selection - Prompt [1] OAuth / [2] API token on first launch, save choice - Validate API tokens against /api/v1/users/self before saving - Check saved API token validity on reuse; fallback to re-prompt on failure - Add HTTP status check to GetCourseInfo and GetEnrolledCourses - Remove --api flag, auth method is now persistent and interactive --- README.md | 18 +++------ cmd/canvasarchiver/main.go | 9 +---- internal/auth/auth.go | 78 ++++++++++++++++++++++++++++++++------ internal/canvas/client.go | 15 ++++++++ internal/models/models.go | 1 + 5 files changed, 89 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index d522862..312b9e8 100644 --- a/README.md +++ b/README.md @@ -31,15 +31,11 @@ go build -o canvasarchiver ./cmd/canvasarchiver ./canvasarchiver -vo ``` - Or to use a manually generated Canvas API token: - ```bash - ./canvasarchiver --api - ``` +2. On first run, you'll be prompted to choose an authentication method: + - **[1] Login via browser (OAuth)**: Visit the provided URL, authorize, and paste the code + - **[2] Use Canvas API token**: Enter a manually generated API token (e.g. `12230~...`) -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 + The choice is saved and reused on subsequent runs. 3. Enter your Course ID when prompted (or use `-me` to download all enrolled courses) @@ -58,8 +54,6 @@ go build -o canvasarchiver ./cmd/canvasarchiver | `-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. | -| `--api` | Use a manually generated Canvas API token instead of OAuth | - ## Configuration @@ -72,9 +66,7 @@ The following constants can be modified in `internal/config/config.go`: ## Authentication -Credentials are stored in `credentials.json` after the first successful login. The refresh token is automatically used for subsequent runs. - -With `--api`, enter a manually generated Canvas API token on first use. It is saved to `credentials.json` and reused on later `--api` runs. +Credentials are stored in `credentials.json` after the first successful authentication. The chosen method is remembered for subsequent runs — OAuth refresh tokens are automatically refreshed, and API tokens are validated before saving. ## Notes diff --git a/cmd/canvasarchiver/main.go b/cmd/canvasarchiver/main.go index eed401c..d8237e0 100644 --- a/cmd/canvasarchiver/main.go +++ b/cmd/canvasarchiver/main.go @@ -17,19 +17,12 @@ func main() { videosOnly := flag.Bool("vo", false, "Videos only mode - download only Panopto videos to a single directory") me := flag.Bool("me", false, "Download all enrolled courses") moduleNumbers := flag.Bool("n", false, "Prefix modules with order numbers [1], [2], etc.") - apiToken := flag.Bool("api", false, "Use a manually generated Canvas API token instead of OAuth") flag.Parse() httpClient := &http.Client{} authenticator := auth.NewAuthenticator(httpClient) - var accessToken string - var err error - if *apiToken { - accessToken, err = authenticator.GetAPIToken() - } else { - accessToken, err = authenticator.GetAccessToken() - } + accessToken, err := authenticator.GetToken() if err != nil { fmt.Printf("Authentication failed: %v\n", err) return diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 9451ebc..140f35f 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -27,7 +27,32 @@ func NewAuthenticator(client *http.Client) *Authenticator { } } -func (a *Authenticator) GetAccessToken() (string, error) { +func (a *Authenticator) GetToken() (string, error) { + creds, err := LoadCredentials() + if err == nil && creds.AuthMethod != "" { + switch creds.AuthMethod { + case "api": + token := strings.TrimSpace(creds.APIToken) + if token == "" { + return a.promptMethod() + } + fmt.Print("[*] Validating saved API token... ") + if err := a.validateAPIToken(token); err != nil { + fmt.Println("FAILED") + fmt.Printf("[!] %v\n", err) + SaveCredentials(&models.Credentials{}) + return a.promptMethod() + } + fmt.Println("OK") + return token, nil + case "oauth": + return a.refreshOrLogin() + } + } + return a.promptMethod() +} + +func (a *Authenticator) refreshOrLogin() (string, error) { creds, err := LoadCredentials() if err != nil { return a.login() @@ -52,36 +77,66 @@ func (a *Authenticator) GetAccessToken() (string, error) { return tr.AccessToken, nil } -func (a *Authenticator) GetAPIToken() (string, error) { - creds, err := LoadCredentials() - if err == nil && strings.TrimSpace(creds.APIToken) != "" { - fmt.Println("[*] Reusing saved API token...") - return strings.TrimSpace(creds.APIToken), nil +func (a *Authenticator) promptMethod() (string, error) { + fmt.Println("Select authentication method:") + fmt.Println(" [1] Login via browser (OAuth)") + fmt.Println(" [2] Use Canvas API token") + fmt.Print("Choice (1/2): ") + input, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil && err != io.EOF { + return "", err } - if err != nil { - creds = &models.Credentials{} + switch strings.TrimSpace(input) { + case "2": + return a.setupAPIToken() + default: + return a.login() } +} +func (a *Authenticator) setupAPIToken() (string, error) { fmt.Print("Enter Canvas API token: ") token, err := bufio.NewReader(os.Stdin).ReadString('\n') if err != nil && err != io.EOF { return "", err } - token = strings.TrimSpace(token) if token == "" { return "", fmt.Errorf("empty API token") } - creds.APIToken = token - if err := SaveCredentials(creds); err != nil { + fmt.Print("[*] Validating API token... ") + if err := a.validateAPIToken(token); err != nil { + fmt.Println("FAILED") return "", err } + fmt.Println("OK") + if err := SaveCredentials(&models.Credentials{ + AuthMethod: "api", + APIToken: token, + }); err != nil { + return "", err + } fmt.Println("[+] API token saved.") return token, nil } +func (a *Authenticator) validateAPIToken(token string) error { + req, _ := http.NewRequest("GET", config.BaseURL+"/api/v1/users/self", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := a.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("invalid token: %d %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + return nil +} + func (a *Authenticator) login() (string, error) { codeVerifier, codeChallenge, err := generatePKCEPair() if err != nil { @@ -211,6 +266,7 @@ func SaveRefreshToken(refreshToken string) error { creds = &models.Credentials{} } creds.RefreshToken = refreshToken + creds.AuthMethod = "oauth" return SaveCredentials(creds) } diff --git a/internal/canvas/client.go b/internal/canvas/client.go index 09bad89..a08281e 100644 --- a/internal/canvas/client.go +++ b/internal/canvas/client.go @@ -16,6 +16,13 @@ import ( "git.directme.in/Joren/CanvasArchiver/internal/utils" ) +func checkAPIResponse(resp *http.Response) error { + if resp.StatusCode < 300 { + return nil + } + return fmt.Errorf("API error: %d", resp.StatusCode) +} + type Client struct { HTTPClient *http.Client AccessToken string @@ -48,6 +55,10 @@ func (c *Client) GetCourseInfo() error { } defer resp.Body.Close() + if err := checkAPIResponse(resp); err != nil { + return err + } + var course models.Course json.NewDecoder(resp.Body).Decode(&course) @@ -64,6 +75,10 @@ func (c *Client) GetEnrolledCourses() ([]models.Course, error) { } defer resp.Body.Close() + if err := checkAPIResponse(resp); err != nil { + return nil, err + } + var courses []models.Course json.NewDecoder(resp.Body).Decode(&courses) return courses, nil diff --git a/internal/models/models.go b/internal/models/models.go index 40643ec..af1be06 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -1,6 +1,7 @@ package models type Credentials struct { + AuthMethod string `json:"auth_method,omitempty"` RefreshToken string `json:"refresh_token,omitempty"` APIToken string `json:"api_token,omitempty"` } -- 2.49.1