Skip to content

Commit 622a304

Browse files
Merge pull request #261 from dropbox/share-link-download-path
Add --path flag to share-link download
2 parents 9ad7667 + 4b705b3 commit 622a304

3 files changed

Lines changed: 243 additions & 14 deletions

File tree

README.md

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,8 @@ All `--sort`, `--reverse`, `--time`, and `--time-format` flags work with both `l
227227

228228
### Sharing
229229

230+
Create shared links:
231+
230232
```sh
231233
$ dbxcli share-link create /file.txt # create or return an existing shared link
232234
$ dbxcli share-link create /file.txt --access viewer # create a link with requested access
@@ -236,21 +238,48 @@ $ dbxcli share-link create /file.txt --disallow-download # create a shared link
236238
$ dbxcli share-link create /file.txt --expires 2026-07-01T00:00:00Z # create an expiring shared link
237239
$ dbxcli share-link create /file.txt --password-prompt # create a password-protected shared link
238240
$ dbxcli share-link create /file.txt --remove-expiration # remove expiration when returning an existing link
239-
$ dbxcli share-link download <url> [target] # download a shared-link file
240-
$ dbxcli share-link download <url> [target] --recursive # download a folder shared link
241+
```
242+
243+
Inspect and list shared links:
244+
245+
```sh
241246
$ dbxcli share-link info <url> # display shared link information
242247
$ dbxcli share-link info <url> --path /nested/file.txt # display information for a path inside the shared link
243248
$ dbxcli share-link list # list existing shared links
244249
$ dbxcli share-link list /file.txt # list direct shared links for a path
245-
$ dbxcli share-link revoke <url> # revoke a shared link
246-
$ dbxcli share-link revoke --path /file.txt # revoke direct shared links for a path
250+
```
251+
252+
Download shared links:
253+
254+
```sh
255+
$ dbxcli share-link download <url> [target] # download a shared-link file
256+
$ dbxcli share-link download <url> --path /nested/file.txt # download a file inside a folder shared link
257+
$ dbxcli share-link download <url> ./local.txt --path /nested/file.txt # download nested file to a local target
258+
$ dbxcli share-link download <url> [target] --recursive # download a folder shared link
259+
```
260+
261+
Update shared links:
262+
263+
```sh
247264
$ dbxcli share-link update <url> --allow-download # update shared link settings
248265
$ dbxcli share-link update <url> --disallow-download # disable downloads from a shared link
249266
$ dbxcli share-link update <url> --audience public # update shared link audience
250267
$ dbxcli share-link update <url> --expires 2026-07-01T00:00:00Z # update shared link expiration
251268
$ dbxcli share-link update <url> --remove-expiration # remove shared link expiration
252269
$ dbxcli share-link update <url> --password-prompt # set or change a shared link password
253270
$ dbxcli share-link update <url> --remove-password # remove a shared link password
271+
```
272+
273+
Revoke shared links:
274+
275+
```sh
276+
$ dbxcli share-link revoke <url> # revoke a shared link
277+
$ dbxcli share-link revoke --path /file.txt # revoke direct shared links for a path
278+
```
279+
280+
Compatibility and shared folders:
281+
282+
```sh
254283
$ dbxcli share list link # deprecated compatibility command
255284
$ dbxcli share list folder # list shared folders
256285
```
@@ -263,7 +292,7 @@ Dropbox account, team, and folder policies can reject shared-link settings such
263292

264293
`share-link create`, `share-link update`, `share-link info`, and `share-link download` support `--password <value>`, `--password-prompt`, and `--password-file <path>` for password-protected links. Use `--password-prompt` for interactive use so the password is not echoed.
265294

266-
`share-link download` writes to the metadata filename when `target` is omitted. Use `-` as the target to write file bytes to stdout. Folder shared links require `--recursive` and cannot be written to stdout.
295+
`share-link download` writes to the metadata filename when `target` is omitted. Use `--path` to download a single file inside a folder shared link. Use `-` as the target to write file bytes to stdout. Folder shared links require `--recursive` and cannot be written to stdout.
267296

268297
New and changed commands should write command results to stdout. Status, progress, warnings, diagnostics, and verbose logs should go to stderr.
269298

cmd/share_link_download.go

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ import (
3030
"github.com/spf13/cobra"
3131
)
3232

33+
type shareLinkDownloadOptions struct {
34+
path string
35+
password sharedLinkPasswordOptions
36+
recursive bool
37+
}
38+
3339
func shareLinkDownload(cmd *cobra.Command, args []string) error {
3440
if len(args) == 0 || len(args) > 2 {
3541
return errors.New("`share-link download` requires a `url` and optional `target` argument")
@@ -49,27 +55,27 @@ func shareLinkDownload(cmd *cobra.Command, args []string) error {
4955
}
5056

5157
arg := sharing.NewGetSharedLinkMetadataArg(url)
52-
password, err := sharedLinkPasswordFromFlags(cmd)
58+
opts, err := parseShareLinkDownloadOptions(cmd)
5359
if err != nil {
5460
return err
5561
}
56-
if password.set {
57-
arg.LinkPassword = password.password
62+
if opts.password.set {
63+
arg.LinkPassword = opts.password.password
5864
}
5965

60-
recursive, err := cmd.Flags().GetBool("recursive")
61-
if err != nil {
62-
return err
66+
dbx := newSharedLinkClient(config)
67+
if opts.path != "" {
68+
arg.Path = opts.path
69+
return downloadSharedLinkPath(cmd, dbx, arg, target)
6370
}
6471

65-
dbx := newSharedLinkClient(config)
6672
link, err := dbx.GetSharedLinkMetadata(arg)
6773
if err != nil {
6874
return err
6975
}
7076

7177
if folder, ok := link.(*sharing.FolderLinkMetadata); ok {
72-
if !recursive {
78+
if !opts.recursive {
7379
return errors.New("shared link is a folder (use --recursive to download folders)")
7480
}
7581
if target == "-" {
@@ -88,7 +94,7 @@ func shareLinkDownload(cmd *cobra.Command, args []string) error {
8894
}
8995

9096
if target == "-" {
91-
if recursive {
97+
if opts.recursive {
9298
return errors.New("`share-link download -` cannot be used with --recursive")
9399
}
94100
if err := downloadSharedLinkToStdout(dbx, arg, cmd.OutOrStdout()); err != nil {
@@ -106,6 +112,63 @@ func shareLinkDownload(cmd *cobra.Command, args []string) error {
106112
return nil
107113
}
108114

115+
func parseShareLinkDownloadOptions(cmd *cobra.Command) (shareLinkDownloadOptions, error) {
116+
var opts shareLinkDownloadOptions
117+
118+
password, err := sharedLinkPasswordFromFlags(cmd)
119+
if err != nil {
120+
return opts, err
121+
}
122+
opts.password = password
123+
124+
recursive, err := cmd.Flags().GetBool("recursive")
125+
if err != nil {
126+
return opts, err
127+
}
128+
opts.recursive = recursive
129+
130+
if localFlagChanged(cmd, "path") {
131+
pathArg, err := localStringFlag(cmd, "path")
132+
if err != nil {
133+
return opts, err
134+
}
135+
if pathArg == "" {
136+
return opts, errors.New("`--path` requires a non-empty path")
137+
}
138+
path, err := validatePath(pathArg)
139+
if err != nil {
140+
return opts, err
141+
}
142+
if path == "" {
143+
return opts, errors.New("cannot download shared-link root with `--path`")
144+
}
145+
opts.path = path
146+
}
147+
148+
if opts.path != "" && opts.recursive {
149+
return opts, errors.New("`--path` cannot be used with --recursive")
150+
}
151+
152+
return opts, nil
153+
}
154+
155+
func downloadSharedLinkPath(cmd *cobra.Command, dbx sharedLinkClient, arg *sharing.GetSharedLinkMetadataArg, target string) error {
156+
if target == "-" {
157+
if err := downloadSharedLinkToStdout(dbx, arg, cmd.OutOrStdout()); err != nil {
158+
return err
159+
}
160+
commandVerboseStatus(cmd, "Downloaded shared link path %s to stdout", arg.Path)
161+
return nil
162+
}
163+
164+
dst, err := downloadSharedLinkToFile(dbx, arg, target, cmd.ErrOrStderr())
165+
if err != nil {
166+
return err
167+
}
168+
commandVerboseStatus(cmd, "Downloaded shared link path %s to %s", arg.Path, dst)
169+
return nil
170+
}
171+
109172
func sharedLinkFolderDownloadTarget(target string, link *sharing.FolderLinkMetadata) (string, error) {
110173
name := link.Name
111174
name = filepath.Base(filepath.FromSlash(name))
@@ -438,18 +501,21 @@ var shareLinkDownloadCmd = &cobra.Command{
438501
Short: "Download a shared link file",
439502
Long: `Download a file from a Dropbox shared link.
440503
- If target is omitted, the local filename comes from shared-link metadata.
504+
- Use --path to download a file inside a folder shared link.
441505
- Use - as target to write file bytes to stdout.
442506
Stdout is byte-clean: all progress and errors go to stderr.
443507
- Use --recursive (-r) to download folder shared links.
444508
`,
445509
Example: ` dbxcli share-link download https://www.dropbox.com/s/example/file.txt
446510
dbxcli share-link download https://www.dropbox.com/s/example/file.txt ./local-file.txt
511+
dbxcli share-link download https://www.dropbox.com/s/example/folder --path /nested/file.txt
447512
dbxcli share-link download https://www.dropbox.com/s/example/file.txt - | tar tz`,
448513
RunE: shareLinkDownload,
449514
}
450515

451516
func init() {
452517
addSharedLinkPasswordFlags(shareLinkDownloadCmd)
518+
shareLinkDownloadCmd.Flags().String("path", "", "Download a file path inside a folder shared link")
453519
shareLinkDownloadCmd.Flags().BoolP("recursive", "r", false, "Recursively download a folder shared link")
454520
shareLinkCmd.AddCommand(shareLinkDownloadCmd)
455521
}

cmd/share_link_download_test.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,136 @@ func TestShareLinkDownloadUsesTargetDirectory(t *testing.T) {
273273
assertFileContent(t, filepath.Join(targetDir, "report.txt"), content)
274274
}
275275

276+
func TestShareLinkDownloadPathDownloadsNestedFile(t *testing.T) {
277+
tmp := t.TempDir()
278+
targetDir := filepath.Join(tmp, "downloads")
279+
if err := os.Mkdir(targetDir, 0755); err != nil {
280+
t.Fatalf("mkdir: %v", err)
281+
}
282+
283+
content := "nested content"
284+
var requested *sharing.GetSharedLinkMetadataArg
285+
stubSharedLinkClient(t, &mockSharedLinkClient{
286+
getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) {
287+
t.Fatal("GetSharedLinkMetadata should not be called for --path file downloads")
288+
return nil, nil
289+
},
290+
getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) {
291+
requested = arg
292+
return downloadableSharedLinkFile("nested.txt", "/docs/sub/nested.txt", "https://example.com/folder", uint64(len(content))),
293+
io.NopCloser(strings.NewReader(content)), nil
294+
},
295+
})
296+
297+
cmd := newShareLinkDownloadTestCommand(nil, nil)
298+
if err := cmd.Flags().Set("path", "sub/nested.txt"); err != nil {
299+
t.Fatalf("set path: %v", err)
300+
}
301+
if err := cmd.Flags().Set("password", "secret"); err != nil {
302+
t.Fatalf("set password: %v", err)
303+
}
304+
305+
if err := shareLinkDownload(cmd, []string{"https://example.com/folder", targetDir}); err != nil {
306+
t.Fatalf("shareLinkDownload error: %v", err)
307+
}
308+
309+
if requested == nil {
310+
t.Fatal("GetSharedLinkFile was not called")
311+
}
312+
if requested.Url != "https://example.com/folder" {
313+
t.Fatalf("url = %q, want https://example.com/folder", requested.Url)
314+
}
315+
if requested.Path != "/sub/nested.txt" {
316+
t.Fatalf("path = %q, want /sub/nested.txt", requested.Path)
317+
}
318+
if requested.LinkPassword != "secret" {
319+
t.Fatalf("password = %q, want secret", requested.LinkPassword)
320+
}
321+
assertFileContent(t, filepath.Join(targetDir, "nested.txt"), content)
322+
}
323+
324+
func TestShareLinkDownloadPathToStdoutIsByteClean(t *testing.T) {
325+
content := "nested stdout content"
326+
var requested *sharing.GetSharedLinkMetadataArg
327+
stubSharedLinkClient(t, &mockSharedLinkClient{
328+
getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) {
329+
requested = arg
330+
return downloadableSharedLinkFile("nested.txt", "/docs/sub/nested.txt", "https://example.com/folder", uint64(len(content))),
331+
io.NopCloser(strings.NewReader(content)), nil
332+
},
333+
})
334+
335+
var stdout bytes.Buffer
336+
var stderr bytes.Buffer
337+
cmd := newShareLinkDownloadTestCommand(&stdout, &stderr)
338+
if err := cmd.Flags().Set("path", "/sub/nested.txt"); err != nil {
339+
t.Fatalf("set path: %v", err)
340+
}
341+
342+
if err := shareLinkDownload(cmd, []string{"https://example.com/folder", "-"}); err != nil {
343+
t.Fatalf("shareLinkDownload error: %v", err)
344+
}
345+
if requested == nil {
346+
t.Fatal("GetSharedLinkFile was not called")
347+
}
348+
if requested.Path != "/sub/nested.txt" {
349+
t.Fatalf("path = %q, want /sub/nested.txt", requested.Path)
350+
}
351+
if stdout.String() != content {
352+
t.Fatalf("stdout = %q, want file bytes", stdout.String())
353+
}
354+
if stderr.String() != "" {
355+
t.Fatalf("stderr = %q, want empty without verbose", stderr.String())
356+
}
357+
}
358+
359+
func TestShareLinkDownloadPathRejectsInvalidCombinations(t *testing.T) {
360+
tests := []struct {
361+
name string
362+
path string
363+
recursive bool
364+
want string
365+
}{
366+
{name: "empty path", path: "", want: "`--path` requires a non-empty path"},
367+
{name: "root path", path: "/", want: "cannot download shared-link root with `--path`"},
368+
{name: "recursive path", path: "/sub/nested.txt", recursive: true, want: "`--path` cannot be used with --recursive"},
369+
}
370+
371+
for _, tt := range tests {
372+
t.Run(tt.name, func(t *testing.T) {
373+
called := false
374+
stubSharedLinkClient(t, &mockSharedLinkClient{
375+
getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) {
376+
called = true
377+
return nil, nil, nil
378+
},
379+
getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) {
380+
called = true
381+
return nil, nil
382+
},
383+
})
384+
385+
cmd := newShareLinkDownloadTestCommand(nil, nil)
386+
if err := cmd.Flags().Set("path", tt.path); err != nil {
387+
t.Fatalf("set path: %v", err)
388+
}
389+
if tt.recursive {
390+
if err := cmd.Flags().Set("recursive", "true"); err != nil {
391+
t.Fatalf("set recursive: %v", err)
392+
}
393+
}
394+
395+
err := shareLinkDownload(cmd, []string{"https://example.com/folder", filepath.Join(t.TempDir(), "target")})
396+
if err == nil || !strings.Contains(err.Error(), tt.want) {
397+
t.Fatalf("error = %v, want %q", err, tt.want)
398+
}
399+
if called {
400+
t.Fatal("shared link API should not be called")
401+
}
402+
})
403+
}
404+
}
405+
276406
func TestShareLinkDownloadFolderRequiresRecursive(t *testing.T) {
277407
called := false
278408
stubSharedLinkClient(t, &mockSharedLinkClient{
@@ -747,6 +877,9 @@ func TestShareLinkDownloadCommandIsRegistered(t *testing.T) {
747877
if shareLinkDownloadCmd.Flags().Lookup("password-file") == nil {
748878
t.Fatal("share-link download should define --password-file")
749879
}
880+
if shareLinkDownloadCmd.Flags().Lookup("path") == nil {
881+
t.Fatal("share-link download should define --path")
882+
}
750883
if shareLinkDownloadCmd.Flags().Lookup("recursive") == nil {
751884
t.Fatal("share-link download should define --recursive")
752885
}
@@ -755,6 +888,7 @@ func TestShareLinkDownloadCommandIsRegistered(t *testing.T) {
755888
func newShareLinkDownloadTestCommand(stdout, stderr *bytes.Buffer) *cobra.Command {
756889
cmd := &cobra.Command{}
757890
addSharedLinkPasswordFlags(cmd)
891+
cmd.Flags().String("path", "", "")
758892
cmd.Flags().BoolP("recursive", "r", false, "")
759893
cmd.Flags().Bool("verbose", false, "")
760894
if stdout != nil {

0 commit comments

Comments
 (0)