diff --git a/README.md b/README.md index eb8c4a7..403f1fa 100644 --- a/README.md +++ b/README.md @@ -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 @@ -236,14 +238,29 @@ $ 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 [target] # download a shared-link file -$ dbxcli share-link download [target] --recursive # download a folder shared link +``` + +Inspect and list shared links: + +```sh $ dbxcli share-link info # display shared link information $ dbxcli share-link info --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 # 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 [target] # download a shared-link file +$ dbxcli share-link download --path /nested/file.txt # download a file inside a folder shared link +$ dbxcli share-link download ./local.txt --path /nested/file.txt # download nested file to a local target +$ dbxcli share-link download [target] --recursive # download a folder shared link +``` + +Update shared links: + +```sh $ dbxcli share-link update --allow-download # update shared link settings $ dbxcli share-link update --disallow-download # disable downloads from a shared link $ dbxcli share-link update --audience public # update shared link audience @@ -251,6 +268,18 @@ $ dbxcli share-link update --expires 2026-07-01T00:00:00Z # update shared $ dbxcli share-link update --remove-expiration # remove shared link expiration $ dbxcli share-link update --password-prompt # set or change a shared link password $ dbxcli share-link update --remove-password # remove a shared link password +``` + +Revoke shared links: + +```sh +$ dbxcli share-link revoke # 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 ``` @@ -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 `, `--password-prompt`, and `--password-file ` 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. diff --git a/cmd/share_link_download.go b/cmd/share_link_download.go index 2408c1a..ca71a70 100644 --- a/cmd/share_link_download.go +++ b/cmd/share_link_download.go @@ -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") @@ -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 == "-" { @@ -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 { @@ -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)) @@ -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) } diff --git a/cmd/share_link_download_test.go b/cmd/share_link_download_test.go index 8d5de7b..0d28112 100644 --- a/cmd/share_link_download_test.go +++ b/cmd/share_link_download_test.go @@ -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{ @@ -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") } @@ -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 {