Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ All `--sort`, `--reverse`, `--time`, and `--time-format` flags work with both `l

### Sharing

Create shared links:

```sh
$ dbxcli share-link create /file.txt # create or return an existing shared link
$ dbxcli share-link create /file.txt --access viewer # create a link with requested access
Expand All @@ -236,21 +238,48 @@ $ dbxcli share-link create /file.txt --disallow-download # create a shared link
$ dbxcli share-link create /file.txt --expires 2026-07-01T00:00:00Z # create an expiring shared link
$ dbxcli share-link create /file.txt --password-prompt # create a password-protected shared link
$ dbxcli share-link create /file.txt --remove-expiration # remove expiration when returning an existing link
$ dbxcli share-link download <url> [target] # download a shared-link file
$ dbxcli share-link download <url> [target] --recursive # download a folder shared link
```

Inspect and list shared links:

```sh
$ dbxcli share-link info <url> # display shared link information
$ dbxcli share-link info <url> --path /nested/file.txt # display information for a path inside the shared link
$ dbxcli share-link list # list existing shared links
$ dbxcli share-link list /file.txt # list direct shared links for a path
$ dbxcli share-link revoke <url> # revoke a shared link
$ dbxcli share-link revoke --path /file.txt # revoke direct shared links for a path
```

Download shared links:

```sh
$ dbxcli share-link download <url> [target] # download a shared-link file
$ dbxcli share-link download <url> --path /nested/file.txt # download a file inside a folder shared link
$ dbxcli share-link download <url> ./local.txt --path /nested/file.txt # download nested file to a local target
$ dbxcli share-link download <url> [target] --recursive # download a folder shared link
```

Update shared links:

```sh
$ dbxcli share-link update <url> --allow-download # update shared link settings
$ dbxcli share-link update <url> --disallow-download # disable downloads from a shared link
$ dbxcli share-link update <url> --audience public # update shared link audience
$ dbxcli share-link update <url> --expires 2026-07-01T00:00:00Z # update shared link expiration
$ dbxcli share-link update <url> --remove-expiration # remove shared link expiration
$ dbxcli share-link update <url> --password-prompt # set or change a shared link password
$ dbxcli share-link update <url> --remove-password # remove a shared link password
```

Revoke shared links:

```sh
$ dbxcli share-link revoke <url> # revoke a shared link
$ dbxcli share-link revoke --path /file.txt # revoke direct shared links for a path
```

Compatibility and shared folders:

```sh
$ dbxcli share list link # deprecated compatibility command
$ dbxcli share list folder # list shared folders
```
Expand All @@ -263,7 +292,7 @@ Dropbox account, team, and folder policies can reject shared-link settings such

`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.

`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.
`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.

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

Expand Down
84 changes: 75 additions & 9 deletions cmd/share_link_download.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ import (
"github.com/spf13/cobra"
)

type shareLinkDownloadOptions struct {
path string
password sharedLinkPasswordOptions
recursive bool
}

func shareLinkDownload(cmd *cobra.Command, args []string) error {
if len(args) == 0 || len(args) > 2 {
return errors.New("`share-link download` requires a `url` and optional `target` argument")
Expand All @@ -49,27 +55,27 @@ func shareLinkDownload(cmd *cobra.Command, args []string) error {
}

arg := sharing.NewGetSharedLinkMetadataArg(url)
password, err := sharedLinkPasswordFromFlags(cmd)
opts, err := parseShareLinkDownloadOptions(cmd)
if err != nil {
return err
}
if password.set {
arg.LinkPassword = password.password
if opts.password.set {
arg.LinkPassword = opts.password.password
}

recursive, err := cmd.Flags().GetBool("recursive")
if err != nil {
return err
dbx := newSharedLinkClient(config)
if opts.path != "" {
arg.Path = opts.path
return downloadSharedLinkPath(cmd, dbx, arg, target)
}

dbx := newSharedLinkClient(config)
link, err := dbx.GetSharedLinkMetadata(arg)
if err != nil {
return err
}

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

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

func parseShareLinkDownloadOptions(cmd *cobra.Command) (shareLinkDownloadOptions, error) {
var opts shareLinkDownloadOptions

password, err := sharedLinkPasswordFromFlags(cmd)
if err != nil {
return opts, err
}
opts.password = password

recursive, err := cmd.Flags().GetBool("recursive")
if err != nil {
return opts, err
}
opts.recursive = recursive

if localFlagChanged(cmd, "path") {
pathArg, err := localStringFlag(cmd, "path")
if err != nil {
return opts, err
}
if pathArg == "" {
return opts, errors.New("`--path` requires a non-empty path")
}
path, err := validatePath(pathArg)
if err != nil {
return opts, err
}
if path == "" {
return opts, errors.New("cannot download shared-link root with `--path`")
}
opts.path = path
}

if opts.path != "" && opts.recursive {
return opts, errors.New("`--path` cannot be used with --recursive")
}

return opts, nil
}

func downloadSharedLinkPath(cmd *cobra.Command, dbx sharedLinkClient, arg *sharing.GetSharedLinkMetadataArg, target string) error {
if target == "-" {
if err := downloadSharedLinkToStdout(dbx, arg, cmd.OutOrStdout()); err != nil {
return err
}
commandVerboseStatus(cmd, "Downloaded shared link path %s to stdout", arg.Path)
return nil
}

dst, err := downloadSharedLinkToFile(dbx, arg, target, cmd.ErrOrStderr())
if err != nil {
return err
}
commandVerboseStatus(cmd, "Downloaded shared link path %s to %s", arg.Path, dst)
return nil
}

func sharedLinkFolderDownloadTarget(target string, link *sharing.FolderLinkMetadata) (string, error) {
name := link.Name
name = filepath.Base(filepath.FromSlash(name))
Expand Down Expand Up @@ -438,18 +501,21 @@ var shareLinkDownloadCmd = &cobra.Command{
Short: "Download a shared link file",
Long: `Download a file from a Dropbox shared link.
- If target is omitted, the local filename comes from shared-link metadata.
- Use --path to download a file inside a folder shared link.
- Use - as target to write file bytes to stdout.
Stdout is byte-clean: all progress and errors go to stderr.
- Use --recursive (-r) to download folder shared links.
`,
Example: ` dbxcli share-link download https://www.dropbox.com/s/example/file.txt
dbxcli share-link download https://www.dropbox.com/s/example/file.txt ./local-file.txt
dbxcli share-link download https://www.dropbox.com/s/example/folder --path /nested/file.txt
dbxcli share-link download https://www.dropbox.com/s/example/file.txt - | tar tz`,
RunE: shareLinkDownload,
}

func init() {
addSharedLinkPasswordFlags(shareLinkDownloadCmd)
shareLinkDownloadCmd.Flags().String("path", "", "Download a file path inside a folder shared link")
shareLinkDownloadCmd.Flags().BoolP("recursive", "r", false, "Recursively download a folder shared link")
shareLinkCmd.AddCommand(shareLinkDownloadCmd)
}
134 changes: 134 additions & 0 deletions cmd/share_link_download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,136 @@ func TestShareLinkDownloadUsesTargetDirectory(t *testing.T) {
assertFileContent(t, filepath.Join(targetDir, "report.txt"), content)
}

func TestShareLinkDownloadPathDownloadsNestedFile(t *testing.T) {
tmp := t.TempDir()
targetDir := filepath.Join(tmp, "downloads")
if err := os.Mkdir(targetDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}

content := "nested content"
var requested *sharing.GetSharedLinkMetadataArg
stubSharedLinkClient(t, &mockSharedLinkClient{
getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) {
t.Fatal("GetSharedLinkMetadata should not be called for --path file downloads")
return nil, nil
},
getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) {
requested = arg
return downloadableSharedLinkFile("nested.txt", "/docs/sub/nested.txt", "https://example.com/folder", uint64(len(content))),
io.NopCloser(strings.NewReader(content)), nil
},
})

cmd := newShareLinkDownloadTestCommand(nil, nil)
if err := cmd.Flags().Set("path", "sub/nested.txt"); err != nil {
t.Fatalf("set path: %v", err)
}
if err := cmd.Flags().Set("password", "secret"); err != nil {
t.Fatalf("set password: %v", err)
}

if err := shareLinkDownload(cmd, []string{"https://example.com/folder", targetDir}); err != nil {
t.Fatalf("shareLinkDownload error: %v", err)
}

if requested == nil {
t.Fatal("GetSharedLinkFile was not called")
}
if requested.Url != "https://example.com/folder" {
t.Fatalf("url = %q, want https://example.com/folder", requested.Url)
}
if requested.Path != "/sub/nested.txt" {
t.Fatalf("path = %q, want /sub/nested.txt", requested.Path)
}
if requested.LinkPassword != "secret" {
t.Fatalf("password = %q, want secret", requested.LinkPassword)
}
assertFileContent(t, filepath.Join(targetDir, "nested.txt"), content)
}

func TestShareLinkDownloadPathToStdoutIsByteClean(t *testing.T) {
content := "nested stdout content"
var requested *sharing.GetSharedLinkMetadataArg
stubSharedLinkClient(t, &mockSharedLinkClient{
getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) {
requested = arg
return downloadableSharedLinkFile("nested.txt", "/docs/sub/nested.txt", "https://example.com/folder", uint64(len(content))),
io.NopCloser(strings.NewReader(content)), nil
},
})

var stdout bytes.Buffer
var stderr bytes.Buffer
cmd := newShareLinkDownloadTestCommand(&stdout, &stderr)
if err := cmd.Flags().Set("path", "/sub/nested.txt"); err != nil {
t.Fatalf("set path: %v", err)
}

if err := shareLinkDownload(cmd, []string{"https://example.com/folder", "-"}); err != nil {
t.Fatalf("shareLinkDownload error: %v", err)
}
if requested == nil {
t.Fatal("GetSharedLinkFile was not called")
}
if requested.Path != "/sub/nested.txt" {
t.Fatalf("path = %q, want /sub/nested.txt", requested.Path)
}
if stdout.String() != content {
t.Fatalf("stdout = %q, want file bytes", stdout.String())
}
if stderr.String() != "" {
t.Fatalf("stderr = %q, want empty without verbose", stderr.String())
}
}

func TestShareLinkDownloadPathRejectsInvalidCombinations(t *testing.T) {
tests := []struct {
name string
path string
recursive bool
want string
}{
{name: "empty path", path: "", want: "`--path` requires a non-empty path"},
{name: "root path", path: "/", want: "cannot download shared-link root with `--path`"},
{name: "recursive path", path: "/sub/nested.txt", recursive: true, want: "`--path` cannot be used with --recursive"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
called := false
stubSharedLinkClient(t, &mockSharedLinkClient{
getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) {
called = true
return nil, nil, nil
},
getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) {
called = true
return nil, nil
},
})

cmd := newShareLinkDownloadTestCommand(nil, nil)
if err := cmd.Flags().Set("path", tt.path); err != nil {
t.Fatalf("set path: %v", err)
}
if tt.recursive {
if err := cmd.Flags().Set("recursive", "true"); err != nil {
t.Fatalf("set recursive: %v", err)
}
}

err := shareLinkDownload(cmd, []string{"https://example.com/folder", filepath.Join(t.TempDir(), "target")})
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("error = %v, want %q", err, tt.want)
}
if called {
t.Fatal("shared link API should not be called")
}
})
}
}

func TestShareLinkDownloadFolderRequiresRecursive(t *testing.T) {
called := false
stubSharedLinkClient(t, &mockSharedLinkClient{
Expand Down Expand Up @@ -747,6 +877,9 @@ func TestShareLinkDownloadCommandIsRegistered(t *testing.T) {
if shareLinkDownloadCmd.Flags().Lookup("password-file") == nil {
t.Fatal("share-link download should define --password-file")
}
if shareLinkDownloadCmd.Flags().Lookup("path") == nil {
t.Fatal("share-link download should define --path")
}
if shareLinkDownloadCmd.Flags().Lookup("recursive") == nil {
t.Fatal("share-link download should define --recursive")
}
Expand All @@ -755,6 +888,7 @@ func TestShareLinkDownloadCommandIsRegistered(t *testing.T) {
func newShareLinkDownloadTestCommand(stdout, stderr *bytes.Buffer) *cobra.Command {
cmd := &cobra.Command{}
addSharedLinkPasswordFlags(cmd)
cmd.Flags().String("path", "", "")
cmd.Flags().BoolP("recursive", "r", false, "")
cmd.Flags().Bool("verbose", false, "")
if stdout != nil {
Expand Down