-
Notifications
You must be signed in to change notification settings - Fork 576
feat: add +download shortcut for minutes media download #101
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
e33a29f
feat: add +download shortcut for minutes media download
Ren1104 808e5f6
chore: remove accidentally committed test artifacts from shortcuts/vc
Ren1104 aa1d985
feat: use minute title and auto-detected extension for default downlo…
Ren1104 6b3bc0b
docs: clarify note_doc_token vs verbatim_doc_token and add cover imag…
Ren1104 d6db1f0
refactor: resolve default filename from Content-Disposition instead o…
Ren1104 52cf828
test: add unit and integration tests for minutes +download shortcut
Ren1104 c95b224
fix: add SSRF protection and redirect safety for media download
Ren1104 1434ef1
feat: add batch download with concurrent execution and SSRF protection
Ren1104 ce6ee5b
chore: promote golang.org/x/sync to direct dependency
Ren1104 56e2fb8
fix: resolve copyloopvar and nilerr lint errors
Ren1104 a29e437
fix: replace errgroup with WaitGroup to resolve nilerr lint and trans…
Ren1104 23746bf
feat: unify --minute-tokens flag, add batch download, token validatio…
Ren1104 cc47e84
fix: address PR review — download timeout, UTF-8 truncation, concurre…
Ren1104 0f6f41f
refactor: simplify +download — unify single/batch loop, remove parall…
Ren1104 812d542
fix(minutes): deduplicate filenames in batch download by prefixing to…
Ren1104 23e3a47
fix(minutes): fix gofmt alignment in downloadOpts struct
Ren1104 6637afc
fix(minutes): add transport-level SSRF protection and batch output va…
Ren1104 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,339 @@ | ||
| // Copyright (c) 2026 Lark Technologies Pte. Ltd. | ||
| // SPDX-License-Identifier: MIT | ||
|
|
||
| package minutes | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "io" | ||
| "mime" | ||
| "net/http" | ||
| "os" | ||
| "path/filepath" | ||
| "regexp" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/larksuite/cli/internal/output" | ||
| "github.com/larksuite/cli/internal/validate" | ||
| "github.com/larksuite/cli/shortcuts/common" | ||
| ) | ||
|
|
||
| const ( | ||
| // disableClientTimeout removes the global 30s client timeout for large media downloads. | ||
| // The download is bounded by the caller's context (e.g. Ctrl+C). A fixed timeout | ||
| // would cut off legitimate large file transfers. | ||
| disableClientTimeout = 0 | ||
|
|
||
| maxBatchSize = 50 | ||
| maxDownloadRedirects = 5 | ||
| ) | ||
|
|
||
| // validMinuteToken matches minute tokens: lowercase alphanumeric characters only. | ||
| var validMinuteToken = regexp.MustCompile(`^[a-z0-9]+$`) | ||
|
|
||
| var MinutesDownload = common.Shortcut{ | ||
| Service: "minutes", | ||
| Command: "+download", | ||
| Description: "Download audio/video media file of a minute", | ||
| Risk: "read", | ||
| Scopes: []string{"minutes:minutes.media:export"}, | ||
| AuthTypes: []string{"user", "bot"}, | ||
| HasFormat: true, | ||
| Flags: []common.Flag{ | ||
| {Name: "minute-tokens", Desc: "minute tokens, comma-separated for batch download (max 50)", Required: true}, | ||
| {Name: "output", Desc: "output path: file path for single token, directory for batch (default: current dir)"}, | ||
| {Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"}, | ||
| {Name: "url-only", Type: "bool", Desc: "only print the download URL(s) without downloading"}, | ||
| }, | ||
| Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { | ||
| tokens := common.SplitCSV(runtime.Str("minute-tokens")) | ||
| if len(tokens) == 0 { | ||
| return output.ErrValidation("--minute-tokens is required") | ||
| } | ||
| if len(tokens) > maxBatchSize { | ||
| return output.ErrValidation("--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize) | ||
| } | ||
| for _, token := range tokens { | ||
| if !validMinuteToken.MatchString(token) { | ||
| return output.ErrValidation("invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)", token) | ||
| } | ||
| } | ||
| return nil | ||
| }, | ||
| DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { | ||
| tokens := common.SplitCSV(runtime.Str("minute-tokens")) | ||
| return common.NewDryRunAPI(). | ||
| GET("/open-apis/minutes/v1/minutes/:minute_token/media"). | ||
| Set("minute_tokens", tokens) | ||
| }, | ||
| Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { | ||
| tokens := common.SplitCSV(runtime.Str("minute-tokens")) | ||
| outputPath := runtime.Str("output") | ||
| overwrite := runtime.Bool("overwrite") | ||
| urlOnly := runtime.Bool("url-only") | ||
| errOut := runtime.IO().ErrOut | ||
| single := len(tokens) == 1 | ||
|
|
||
| // Batch mode: --output must be a directory, not an existing file. | ||
| if !single && outputPath != "" { | ||
| if fi, err := os.Stat(outputPath); err == nil && !fi.IsDir() { | ||
| return output.ErrValidation("--output %q is a file; batch mode expects a directory path", outputPath) | ||
| } | ||
| } | ||
|
|
||
| if !single { | ||
| fmt.Fprintf(errOut, "[minutes +download] batch: %d token(s)\n", len(tokens)) | ||
| } | ||
|
|
||
| type result struct { | ||
| MinuteToken string `json:"minute_token"` | ||
| SavedPath string `json:"saved_path,omitempty"` | ||
| SizeBytes int64 `json:"size_bytes,omitempty"` | ||
| DownloadURL string `json:"download_url,omitempty"` | ||
| Error string `json:"error,omitempty"` | ||
| } | ||
|
|
||
| results := make([]result, len(tokens)) | ||
| seen := make(map[string]int) | ||
| usedNames := make(map[string]bool) | ||
|
|
||
| // Clone the factory client for download use. We clone the struct (not the | ||
| // pointer) to avoid mutating the shared singleton's Timeout. The original | ||
| // transport chain is preserved so security headers and test mocks still work. | ||
| // SSRF protection: ValidateDownloadSourceURL (URL-level) + CheckRedirect | ||
| // (redirect-level). Transport-level IP check is intentionally omitted because | ||
| // download URLs originate from the trusted Lark API, not user input. | ||
| baseClient, err := runtime.Factory.HttpClient() | ||
| if err != nil { | ||
| return output.ErrNetwork("failed to get HTTP client: %s", err) | ||
| } | ||
| clonedClient := *baseClient | ||
| clonedClient.Timeout = disableClientTimeout | ||
| clonedClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { | ||
| if len(via) >= maxDownloadRedirects { | ||
| return fmt.Errorf("too many redirects") | ||
| } | ||
| if len(via) > 0 { | ||
| prev := via[len(via)-1] | ||
| if strings.EqualFold(prev.URL.Scheme, "https") && strings.EqualFold(req.URL.Scheme, "http") { | ||
| return fmt.Errorf("redirect from https to http is not allowed") | ||
| } | ||
| } | ||
| return validate.ValidateDownloadSourceURL(req.Context(), req.URL.String()) | ||
| } | ||
| dlClient := &clonedClient | ||
|
|
||
| ticker := time.NewTicker(time.Second / 5) // rate-limit to 5 req/s | ||
| defer ticker.Stop() | ||
|
|
||
| for i, token := range tokens { | ||
| if i > 0 { | ||
| select { | ||
| case <-ctx.Done(): | ||
| return ctx.Err() | ||
| case <-ticker.C: | ||
| } | ||
| } | ||
|
|
||
| if err := validate.ResourceName(token, "--minute-tokens"); err != nil { | ||
| results[i] = result{MinuteToken: token, Error: err.Error()} | ||
| continue | ||
| } | ||
| if firstIdx, dup := seen[token]; dup { | ||
| results[i] = result{MinuteToken: token, Error: fmt.Sprintf("duplicate token, same as index %d", firstIdx)} | ||
| continue | ||
| } | ||
| seen[token] = i | ||
|
|
||
| downloadURL, err := fetchDownloadURL(ctx, runtime, token) | ||
| if err != nil { | ||
| results[i] = result{MinuteToken: token, Error: err.Error()} | ||
| continue | ||
| } | ||
|
|
||
| if urlOnly { | ||
| results[i] = result{MinuteToken: token, DownloadURL: downloadURL} | ||
| continue | ||
| } | ||
|
|
||
| fmt.Fprintf(errOut, "Downloading media: %s\n", common.MaskToken(token)) | ||
|
|
||
| // single token: --output is a file path; batch: --output is a directory | ||
| opts := downloadOpts{overwrite: overwrite, usedNames: usedNames} | ||
| if single { | ||
| opts.outputPath = outputPath | ||
| } else { | ||
| opts.outputDir = outputPath | ||
| } | ||
|
|
||
| dl, err := downloadMediaFile(ctx, dlClient, downloadURL, token, opts) | ||
| if err != nil { | ||
| results[i] = result{MinuteToken: token, Error: err.Error()} | ||
| continue | ||
| } | ||
| results[i] = result{MinuteToken: token, SavedPath: dl.savedPath, SizeBytes: dl.sizeBytes} | ||
| } | ||
|
|
||
| // output | ||
| if single { | ||
| r := results[0] | ||
| if r.Error != "" { | ||
| return output.ErrAPI(0, r.Error, nil) | ||
| } | ||
| if urlOnly { | ||
| runtime.Out(map[string]interface{}{"download_url": r.DownloadURL}, nil) | ||
| } else { | ||
| runtime.Out(map[string]interface{}{"saved_path": r.SavedPath, "size_bytes": r.SizeBytes}, nil) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // batch output | ||
| successCount := 0 | ||
| for _, r := range results { | ||
| if r.Error == "" { | ||
| successCount++ | ||
| } | ||
| } | ||
| fmt.Fprintf(errOut, "[minutes +download] done: %d total, %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount) | ||
|
|
||
| runtime.OutFormat(map[string]interface{}{"downloads": results}, &output.Meta{Count: len(results)}, nil) | ||
| if successCount == 0 && len(results) > 0 { | ||
| return output.ErrAPI(0, fmt.Sprintf("all %d downloads failed", len(results)), nil) | ||
| } | ||
| return nil | ||
| }, | ||
| } | ||
|
|
||
| // fetchDownloadURL retrieves the pre-signed download URL for a minute token. | ||
| func fetchDownloadURL(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) (string, error) { | ||
| data, err := runtime.DoAPIJSON(http.MethodGet, | ||
| fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/media", validate.EncodePathSegment(minuteToken)), | ||
| nil, nil) | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
| downloadURL := common.GetString(data, "download_url") | ||
| if downloadURL == "" { | ||
| return "", output.Errorf(output.ExitAPI, "api_error", "API returned empty download_url for %s", minuteToken) | ||
| } | ||
| return downloadURL, nil | ||
| } | ||
|
|
||
| type downloadResult struct { | ||
| savedPath string | ||
| sizeBytes int64 | ||
| } | ||
|
|
||
| type downloadOpts struct { | ||
| outputPath string // explicit output file path (single mode only) | ||
| outputDir string // output directory (batch mode) | ||
| overwrite bool | ||
| usedNames map[string]bool // tracks used filenames to deduplicate in batch mode | ||
| } | ||
|
|
||
| // downloadMediaFile streams a media file from a pre-signed URL to disk. | ||
| // Filename resolution: opts.outputPath > Content-Disposition filename > Content-Type ext > <token>.media. | ||
| func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, minuteToken string, opts downloadOpts) (*downloadResult, error) { | ||
| if err := validate.ValidateDownloadSourceURL(ctx, downloadURL); err != nil { | ||
| return nil, output.ErrValidation("blocked download URL: %s", err) | ||
| } | ||
|
|
||
| req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) | ||
| if err != nil { | ||
| return nil, output.ErrNetwork("invalid download URL: %s", err) | ||
| } | ||
|
|
||
| resp, err := client.Do(req) | ||
| if err != nil { | ||
| return nil, output.ErrNetwork("download failed: %s", err) | ||
| } | ||
| defer resp.Body.Close() | ||
|
|
||
| if resp.StatusCode < 200 || resp.StatusCode >= 300 { | ||
| body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) | ||
| if len(body) > 0 { | ||
| return nil, output.ErrNetwork("download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) | ||
| } | ||
| return nil, output.ErrNetwork("download failed: HTTP %d", resp.StatusCode) | ||
| } | ||
|
|
||
| // resolve output path | ||
| outputPath := opts.outputPath | ||
| if outputPath == "" { | ||
| filename := resolveFilenameFromResponse(resp, minuteToken) | ||
| // Deduplicate filenames in batch mode: prefix with token on collision. | ||
| if opts.usedNames != nil { | ||
| if opts.usedNames[filename] { | ||
| filename = minuteToken + "-" + filename | ||
| } | ||
| opts.usedNames[filename] = true | ||
| } | ||
| outputPath = filepath.Join(opts.outputDir, filename) | ||
| } | ||
|
|
||
| safePath, err := validate.SafeOutputPath(outputPath) | ||
| if err != nil { | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
| return nil, output.ErrValidation("unsafe output path: %s", err) | ||
| } | ||
| if err := common.EnsureWritableFile(safePath, opts.overwrite); err != nil { | ||
| return nil, err | ||
| } | ||
| if err := os.MkdirAll(filepath.Dir(safePath), 0700); err != nil { | ||
| return nil, output.Errorf(output.ExitInternal, "api_error", "cannot create parent directory: %s", err) | ||
| } | ||
|
|
||
| sizeBytes, err := validate.AtomicWriteFromReader(safePath, resp.Body, 0600) | ||
| if err != nil { | ||
| return nil, output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err) | ||
| } | ||
| return &downloadResult{savedPath: safePath, sizeBytes: sizeBytes}, nil | ||
| } | ||
|
|
||
| // resolveFilenameFromResponse derives the filename from HTTP response headers. | ||
| // Priority: Content-Disposition filename > Content-Type extension > <token>.media. | ||
| func resolveFilenameFromResponse(resp *http.Response, minuteToken string) string { | ||
| if cd := resp.Header.Get("Content-Disposition"); cd != "" { | ||
| if _, params, err := mime.ParseMediaType(cd); err == nil { | ||
| if filename := params["filename"]; filename != "" { | ||
| return filename | ||
| } | ||
| } | ||
| } | ||
| if ext := extFromContentType(resp.Header.Get("Content-Type")); ext != "" { | ||
| return minuteToken + ext | ||
| } | ||
| return minuteToken + ".media" | ||
| } | ||
|
|
||
| // preferredExt overrides Go's mime.ExtensionsByType which returns alphabetically sorted | ||
| // results (e.g. .m4v before .mp4 for video/mp4). | ||
| var preferredExt = map[string]string{ | ||
| "video/mp4": ".mp4", | ||
| "audio/mp4": ".m4a", | ||
| "audio/mpeg": ".mp3", | ||
| } | ||
|
|
||
| // newDownloadClient wraps the base HTTP client with SSRF protection | ||
| // (redirect safety + transport-level IP validation). When the base transport | ||
| // is not *http.Transport (e.g. test mocks), it falls back to cloning | ||
| // http.DefaultTransport via NewDownloadHTTPClient. | ||
| // extFromContentType returns a file extension for the given Content-Type, or "" if unknown. | ||
| func extFromContentType(contentType string) string { | ||
| if contentType == "" { | ||
| return "" | ||
| } | ||
| mediaType, _, err := mime.ParseMediaType(contentType) | ||
| if err != nil { | ||
| return "" | ||
| } | ||
| if ext, ok := preferredExt[mediaType]; ok { | ||
| return ext | ||
| } | ||
| if exts, err := mime.ExtensionsByType(mediaType); err == nil && len(exts) > 0 { | ||
| return exts[0] | ||
| } | ||
| return "" | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.