@@ -5,10 +5,13 @@ package minutes
55
66import (
77 "context"
8+ "errors"
89 "fmt"
910 "io"
11+ "io/fs"
1012 "mime"
1113 "net/http"
14+ "path"
1215 "path/filepath"
1316 "regexp"
1417 "strings"
@@ -43,7 +46,8 @@ var MinutesDownload = common.Shortcut{
4346 HasFormat : true ,
4447 Flags : []common.Flag {
4548 {Name : "minute-tokens" , Desc : "minute tokens, comma-separated for batch download (max 50)" , Required : true },
46- {Name : "output" , Desc : "output path: file path for single token, directory for batch (default: current dir)" },
49+ {Name : "output" , Desc : "output file path (single token)" },
50+ {Name : "output-dir" , Desc : "output directory (default: ./minutes/{minute_token}/)" },
4751 {Name : "overwrite" , Type : "bool" , Desc : "overwrite existing output file" },
4852 {Name : "url-only" , Type : "bool" , Desc : "only print the download URL(s) without downloading" },
4953 },
@@ -60,6 +64,22 @@ var MinutesDownload = common.Shortcut{
6064 return output .ErrValidation ("invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)" , token )
6165 }
6266 }
67+ // Cheap checks first, then path-safety resolution.
68+ out := runtime .Str ("output" )
69+ outDir := runtime .Str ("output-dir" )
70+ if out != "" && outDir != "" {
71+ return output .ErrValidation ("--output and --output-dir cannot both be set" )
72+ }
73+ if out != "" {
74+ if err := common .ValidateSafePath (runtime .FileIO (), out ); err != nil {
75+ return err
76+ }
77+ }
78+ if outDir != "" {
79+ if err := common .ValidateSafePath (runtime .FileIO (), outDir ); err != nil {
80+ return err
81+ }
82+ }
6383 return nil
6484 },
6585 DryRun : func (ctx context.Context , runtime * common.RuntimeContext ) * common.DryRunAPI {
@@ -70,29 +90,53 @@ var MinutesDownload = common.Shortcut{
7090 },
7191 Execute : func (ctx context.Context , runtime * common.RuntimeContext ) error {
7292 tokens := common .SplitCSV (runtime .Str ("minute-tokens" ))
73- outputPath := runtime .Str ("output" )
93+ rawOutput := runtime .Str ("output" )
94+ rawOutputDir := runtime .Str ("output-dir" )
7495 overwrite := runtime .Bool ("overwrite" )
7596 urlOnly := runtime .Bool ("url-only" )
7697 errOut := runtime .IO ().ErrOut
7798 single := len (tokens ) == 1
7899
79- // Batch mode: --output must be a directory, not an existing file.
80- if ! single && outputPath != "" {
81- if fi , err := runtime .FileIO ().Stat (outputPath ); err == nil && ! fi .IsDir () {
82- return output .ErrValidation ("--output %q is a file; batch mode expects a directory path" , outputPath )
100+ // Re-interpret --output based on what the path points to. An existing
101+ // directory is promoted to --output-dir so single-token cp semantics
102+ // work. An existing file is rejected in batch mode (the flag carries
103+ // directory semantics there). Unknown filesystem errors are surfaced
104+ // eagerly rather than deferred to Save.
105+ explicitOutputPath := rawOutput
106+ explicitOutputDir := rawOutputDir
107+ if explicitOutputPath != "" {
108+ fi , statErr := runtime .FileIO ().Stat (explicitOutputPath )
109+ switch {
110+ case statErr == nil && fi .IsDir ():
111+ explicitOutputDir = explicitOutputPath
112+ explicitOutputPath = ""
113+ case statErr == nil && ! fi .IsDir ():
114+ if ! single {
115+ return output .ErrValidation ("--output %q is a file; batch mode expects a directory (use --output-dir)" , explicitOutputPath )
116+ }
117+ case errors .Is (statErr , fs .ErrNotExist ):
118+ if ! single {
119+ explicitOutputDir = explicitOutputPath
120+ explicitOutputPath = ""
121+ }
122+ default :
123+ return output .Errorf (output .ExitAPI , "io_error" , "cannot access --output %q: %s" , explicitOutputPath , statErr )
83124 }
84125 }
85126
127+ useDefaultLayout := explicitOutputPath == "" && explicitOutputDir == ""
128+
86129 if ! single {
87130 fmt .Fprintf (errOut , "[minutes +download] batch: %d token(s)\n " , len (tokens ))
88131 }
89132
90133 type result struct {
91- MinuteToken string `json:"minute_token"`
92- SavedPath string `json:"saved_path,omitempty"`
93- SizeBytes int64 `json:"size_bytes,omitempty"`
94- DownloadURL string `json:"download_url,omitempty"`
95- Error string `json:"error,omitempty"`
134+ MinuteToken string `json:"minute_token"`
135+ ArtifactType string `json:"artifact_type,omitempty"`
136+ SavedPath string `json:"saved_path,omitempty"`
137+ SizeBytes int64 `json:"size_bytes,omitempty"`
138+ DownloadURL string `json:"download_url,omitempty"`
139+ Error string `json:"error,omitempty"`
96140 }
97141
98142 results := make ([]result , len (tokens ))
@@ -160,20 +204,31 @@ var MinutesDownload = common.Shortcut{
160204
161205 fmt .Fprintf (errOut , "Downloading media: %s\n " , common .MaskToken (token ))
162206
163- // single token: --output is a file path; batch: --output is a directory
164- opts := downloadOpts {fio : runtime .FileIO (), overwrite : overwrite , usedNames : usedNames }
165- if single {
166- opts .outputPath = outputPath
167- } else {
168- opts .outputDir = outputPath
207+ opts := downloadOpts {fio : runtime .FileIO (), overwrite : overwrite }
208+ switch {
209+ case useDefaultLayout :
210+ // Per-token subdirectory guarantees unique paths, so no dedup map.
211+ opts .outputDir = common .DefaultMinuteArtifactDir (token )
212+ case explicitOutputPath != "" && single :
213+ opts .outputPath = explicitOutputPath
214+ default :
215+ opts .outputDir = explicitOutputDir
216+ if ! single {
217+ opts .usedNames = usedNames
218+ }
169219 }
170220
171221 dl , err := downloadMediaFile (ctx , dlClient , downloadURL , token , opts )
172222 if err != nil {
173223 results [i ] = result {MinuteToken : token , Error : err .Error ()}
174224 continue
175225 }
176- results [i ] = result {MinuteToken : token , SavedPath : dl .savedPath , SizeBytes : dl .sizeBytes }
226+ results [i ] = result {
227+ MinuteToken : token ,
228+ ArtifactType : common .ArtifactTypeRecording ,
229+ SavedPath : dl .savedPath ,
230+ SizeBytes : dl .sizeBytes ,
231+ }
177232 }
178233
179234 // output
@@ -183,9 +238,17 @@ var MinutesDownload = common.Shortcut{
183238 return output .ErrAPI (0 , r .Error , nil )
184239 }
185240 if urlOnly {
186- runtime .Out (map [string ]interface {}{"download_url" : r .DownloadURL }, nil )
241+ runtime .Out (map [string ]interface {}{
242+ "minute_token" : r .MinuteToken ,
243+ "download_url" : r .DownloadURL ,
244+ }, nil )
187245 } else {
188- runtime .Out (map [string ]interface {}{"saved_path" : r .SavedPath , "size_bytes" : r .SizeBytes }, nil )
246+ runtime .Out (map [string ]interface {}{
247+ "minute_token" : r .MinuteToken ,
248+ "artifact_type" : r .ArtifactType ,
249+ "saved_path" : r .SavedPath ,
250+ "size_bytes" : r .SizeBytes ,
251+ }, nil )
189252 }
190253 return nil
191254 }
@@ -230,7 +293,7 @@ type downloadResult struct {
230293type downloadOpts struct {
231294 fio fileio.FileIO // file I/O abstraction
232295 outputPath string // explicit output file path (single mode only)
233- outputDir string // output directory (batch mode )
296+ outputDir string // output directory (single or batch )
234297 overwrite bool
235298 usedNames map [string ]bool // tracks used filenames to deduplicate in batch mode
236299}
@@ -300,7 +363,7 @@ func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, mi
300363func resolveFilenameFromResponse (resp * http.Response , minuteToken string ) string {
301364 if cd := resp .Header .Get ("Content-Disposition" ); cd != "" {
302365 if _ , params , err := mime .ParseMediaType (cd ); err == nil {
303- if filename := params ["filename" ]; filename != "" {
366+ if filename := sanitizeServerFilename ( params ["filename" ]) ; filename != "" {
304367 return filename
305368 }
306369 }
@@ -311,6 +374,20 @@ func resolveFilenameFromResponse(resp *http.Response, minuteToken string) string
311374 return minuteToken + ".media"
312375}
313376
377+ // sanitizeServerFilename reduces a server-provided filename to its basename,
378+ // defending against Content-Disposition payloads that embed directory
379+ // separators (e.g. "../other.mp4") and would otherwise escape the intended
380+ // artifact directory after filepath.Join. Empty or dot-only names return ""
381+ // so the caller can fall back to the next naming strategy.
382+ func sanitizeServerFilename (filename string ) string {
383+ filename = strings .ReplaceAll (filename , "\\ " , "/" )
384+ filename = path .Base (filename )
385+ if filename == "" || filename == "." || filename == ".." {
386+ return ""
387+ }
388+ return filename
389+ }
390+
314391// preferredExt overrides Go's mime.ExtensionsByType which returns alphabetically sorted
315392// results (e.g. .m4v before .mp4 for video/mp4).
316393var preferredExt = map [string ]string {
0 commit comments