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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,22 +233,28 @@ $ dbxcli share-link create /file.txt --access viewer # create a link with reques
$ dbxcli share-link create /file.txt --audience team # create a link with requested audience
$ dbxcli share-link create /file.txt --allow-download # create a downloadable 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
$ dbxcli share-link info <url> # display shared link information
$ 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 update <url> --allow-download # update shared link settings
$ dbxcli share-link update <url> --audience public # update shared link audience
$ 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
$ dbxcli share list link # deprecated compatibility command
$ dbxcli share list folder # list shared folders
```

`share-link create --access` supports `viewer`, `editor`, and `max`. Dropbox does not support changing access for an existing shared link, so `--access` fails clearly if the link already exists.
`share-link create --audience` and `share-link update --audience` support `public`, `team`, `members`, and `no-one`. Dropbox team and folder policies can still resolve the effective audience differently.

`share-link download` writes to the metadata filename when `target` is omitted. Use `-` as the target to write file bytes to stdout, and `--password` for password-protected shared links.
`share-link create`, `share-link update`, 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.

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

Expand Down
171 changes: 171 additions & 0 deletions cmd/share_create_link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type mockSharedLinkClient struct {
getSharedLinkMetadataFn func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error)
listSharedLinksFn func(arg *sharing.ListSharedLinksArg) (*sharing.ListSharedLinksResult, error)
modifySharedLinkSettingsFn func(arg *sharing.ModifySharedLinkSettingsArgs) (sharing.IsSharedLinkMetadata, error)
removeSharedLinkPasswordFn func(url string) error
revokeSharedLinkFn func(arg *sharing.RevokeSharedLinkArg) error
}

Expand Down Expand Up @@ -72,6 +73,13 @@ func (m *mockSharedLinkClient) ModifySharedLinkSettings(arg *sharing.ModifyShare
return nil, nil
}

func (m *mockSharedLinkClient) RemoveSharedLinkPassword(url string) error {
if m.removeSharedLinkPasswordFn != nil {
return m.removeSharedLinkPasswordFn(url)
}
return nil
}

func (m *mockSharedLinkClient) RevokeSharedLink(arg *sharing.RevokeSharedLinkArg) error {
if m.revokeSharedLinkFn != nil {
return m.revokeSharedLinkFn(arg)
Expand Down Expand Up @@ -228,6 +236,123 @@ func TestSharedLinkCreateWithAllowDownloadSetsAllowDownload(t *testing.T) {
}
}

func TestSharedLinkCreateWithPasswordSetsPassword(t *testing.T) {
mock := &mockSharedLinkClient{
createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) {
if arg.Settings == nil {
t.Fatal("settings = nil, want password settings")
}
if !arg.Settings.RequirePassword {
t.Fatal("RequirePassword = false, want true")
}
if arg.Settings.LinkPassword != "secret" {
t.Fatalf("LinkPassword = %q, want secret", arg.Settings.LinkPassword)
}
return sharedLinkFile("/file.txt", "https://example.com/file"), nil
},
}
stubSharedLinkClient(t, mock)

var stdout bytes.Buffer
cmd := &cobra.Command{}
addSharedLinkPasswordFlags(cmd)
cmd.SetOut(&stdout)
if err := cmd.Flags().Set("password", "secret"); err != nil {
t.Fatalf("set password: %v", err)
}

if err := shareLinkCreate(cmd, []string{"/file.txt"}); err != nil {
t.Fatalf("shareLinkCreate error: %v", err)
}
if got := stdout.String(); got != "https://example.com/file\n" {
t.Fatalf("stdout = %q, want URL only", got)
}
}

func TestSharedLinkCreateWithPasswordPromptSetsPassword(t *testing.T) {
orig := readSharedLinkPassword
readSharedLinkPassword = func(prompt string, in io.Reader, errOut io.Writer) (string, error) {
if prompt != "Shared link password: " {
t.Fatalf("prompt = %q, want shared link password prompt", prompt)
}
return "prompt-secret", nil
}
t.Cleanup(func() { readSharedLinkPassword = orig })

mock := &mockSharedLinkClient{
createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) {
if arg.Settings == nil || !arg.Settings.RequirePassword || arg.Settings.LinkPassword != "prompt-secret" {
t.Fatalf("settings = %#v, want prompted password", arg.Settings)
}
return sharedLinkFile("/file.txt", "https://example.com/file"), nil
},
}
stubSharedLinkClient(t, mock)

var stdout bytes.Buffer
cmd := &cobra.Command{}
addSharedLinkPasswordFlags(cmd)
cmd.SetOut(&stdout)
if err := cmd.Flags().Set("password-prompt", "true"); err != nil {
t.Fatalf("set password-prompt: %v", err)
}

if err := shareLinkCreate(cmd, []string{"/file.txt"}); err != nil {
t.Fatalf("shareLinkCreate error: %v", err)
}
}

func TestSharedLinkCreateRejectsMultiplePasswordSources(t *testing.T) {
called := false
stubSharedLinkClient(t, &mockSharedLinkClient{
createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) {
called = true
return nil, nil
},
})

cmd := &cobra.Command{}
addSharedLinkPasswordFlags(cmd)
if err := cmd.Flags().Set("password", "secret"); err != nil {
t.Fatalf("set password: %v", err)
}
if err := cmd.Flags().Set("password-prompt", "true"); err != nil {
t.Fatalf("set password-prompt: %v", err)
}

err := shareLinkCreate(cmd, []string{"/file.txt"})
if err == nil || !strings.Contains(err.Error(), "use only one of `--password`, `--password-prompt`, or `--password-file`") {
t.Fatalf("error = %v, want password source error", err)
}
if called {
t.Fatal("CreateSharedLinkWithSettings should not be called")
}
}

func TestSharedLinkCreateRejectsEmptyPassword(t *testing.T) {
called := false
stubSharedLinkClient(t, &mockSharedLinkClient{
createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) {
called = true
return nil, nil
},
})

cmd := &cobra.Command{}
addSharedLinkPasswordFlags(cmd)
if err := cmd.Flags().Set("password", ""); err != nil {
t.Fatalf("set password: %v", err)
}

err := shareLinkCreate(cmd, []string{"/file.txt"})
if err == nil || !strings.Contains(err.Error(), "shared link password cannot be empty") {
t.Fatalf("error = %v, want empty password error", err)
}
if called {
t.Fatal("CreateSharedLinkWithSettings should not be called")
}
}

func TestSharedLinkCreateWithAccessSetsAccess(t *testing.T) {
mock := &mockSharedLinkClient{
createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) {
Expand Down Expand Up @@ -739,6 +864,43 @@ func TestSharedLinkCreateWithAllowDownloadUpdatesExistingLink(t *testing.T) {
}
}

func TestSharedLinkCreateWithPasswordUpdatesExistingLink(t *testing.T) {
existing := sharedLinkFile("/file.txt", "https://example.com/file-old")
mock := &mockSharedLinkClient{
createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) {
if arg.Settings == nil || !arg.Settings.RequirePassword || arg.Settings.LinkPassword != "secret" {
t.Fatalf("create settings = %#v, want password settings", arg.Settings)
}
return nil, alreadyExistsError(existing)
},
modifySharedLinkSettingsFn: func(arg *sharing.ModifySharedLinkSettingsArgs) (sharing.IsSharedLinkMetadata, error) {
if arg.Url != "https://example.com/file-old" {
t.Fatalf("modify URL = %q, want existing URL", arg.Url)
}
if arg.Settings == nil || !arg.Settings.RequirePassword || arg.Settings.LinkPassword != "secret" {
t.Fatalf("modify settings = %#v, want password settings", arg.Settings)
}
return sharedLinkFile("/file.txt", "https://example.com/file-new"), nil
},
}
stubSharedLinkClient(t, mock)

var stdout bytes.Buffer
cmd := &cobra.Command{}
addSharedLinkPasswordFlags(cmd)
cmd.SetOut(&stdout)
if err := cmd.Flags().Set("password", "secret"); err != nil {
t.Fatalf("set password: %v", err)
}

if err := shareLinkCreate(cmd, []string{"/file.txt"}); err != nil {
t.Fatalf("shareLinkCreate error: %v", err)
}
if got := stdout.String(); got != "https://example.com/file-new\n" {
t.Fatalf("stdout = %q, want updated URL", got)
}
}

func TestSharedLinkCreateFallbackPrefersExactPathLower(t *testing.T) {
var listArg *sharing.ListSharedLinksArg
mock := &mockSharedLinkClient{
Expand Down Expand Up @@ -873,6 +1035,15 @@ func TestShareLinkCreateDoesNotBreakShareListLinkCommand(t *testing.T) {
if shareLinkCreateCmd.Flags().Lookup("remove-expiration") == nil {
t.Fatal("share-link create should define --remove-expiration")
}
if shareLinkCreateCmd.Flags().Lookup("password") == nil {
t.Fatal("share-link create should define --password")
}
if shareLinkCreateCmd.Flags().Lookup("password-prompt") == nil {
t.Fatal("share-link create should define --password-prompt")
}
if shareLinkCreateCmd.Flags().Lookup("password-file") == nil {
t.Fatal("share-link create should define --password-file")
}

cmd, _, err = RootCmd.Find([]string{"share", "list", "link"})
if err != nil {
Expand Down
11 changes: 10 additions & 1 deletion cmd/share_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,20 @@ type sharedLinkClient interface {
GetSharedLinkMetadata(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error)
ListSharedLinks(arg *sharing.ListSharedLinksArg) (*sharing.ListSharedLinksResult, error)
ModifySharedLinkSettings(arg *sharing.ModifySharedLinkSettingsArgs) (sharing.IsSharedLinkMetadata, error)
RemoveSharedLinkPassword(url string) error
RevokeSharedLink(arg *sharing.RevokeSharedLinkArg) error
}

type sdkSharedLinkClient struct {
sharing.Client
cfg dropbox.Config
}

var newSharedLinkClient = func(cfg dropbox.Config) sharedLinkClient {
return sharing.New(cfg)
return &sdkSharedLinkClient{
Client: sharing.New(cfg),
cfg: cfg,
}
}

var shareLinkCmd = &cobra.Command{
Expand Down
16 changes: 14 additions & 2 deletions cmd/share_link_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type shareLinkCreateOptions struct {
allowDownload bool
access *sharing.RequestedLinkAccessLevel
audience *sharing.LinkAudience
password sharedLinkPasswordOptions
}

func shareLinkCreate(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -133,6 +134,12 @@ func parseShareLinkCreateOptions(cmd *cobra.Command) (shareLinkCreateOptions, er
opts.audience = audience
}

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

if opts.expires != nil && opts.removeExpiration {
return opts, errors.New("`--expires` and `--remove-expiration` cannot be used together")
}
Expand All @@ -144,7 +151,7 @@ func applyExistingSharedLinkCreateOptions(dbx sharedLinkClient, link sharing.IsS
if opts.access != nil {
return nil, errors.New("cannot apply `--access` because the shared link already exists")
}
if opts.expires == nil && !opts.removeExpiration && !opts.allowDownload && opts.audience == nil {
if opts.expires == nil && !opts.removeExpiration && !opts.allowDownload && opts.audience == nil && !opts.password.set {
return link, nil
}

Expand All @@ -163,7 +170,7 @@ func applyExistingSharedLinkCreateOptions(dbx sharedLinkClient, link sharing.IsS
}

func (opts shareLinkCreateOptions) hasCreateSettings() bool {
return opts.expires != nil || opts.allowDownload || opts.access != nil || opts.audience != nil
return opts.expires != nil || opts.allowDownload || opts.access != nil || opts.audience != nil || opts.password.set
}

func applySharedLinkCreateSettings(settings *sharing.SharedLinkSettings, opts shareLinkCreateOptions) {
Expand All @@ -179,6 +186,10 @@ func applySharedLinkCreateSettings(settings *sharing.SharedLinkSettings, opts sh
if opts.audience != nil {
settings.Audience = opts.audience
}
if opts.password.set {
settings.RequirePassword = true
settings.LinkPassword = opts.password.password
}
}

func existingSharedLink(dbx sharedLinkClient, path string, err error) (sharing.IsSharedLinkMetadata, error) {
Expand Down Expand Up @@ -361,5 +372,6 @@ func init() {
shareLinkCreateCmd.Flags().Bool("allow-download", false, "Allow downloads from the shared link")
shareLinkCreateCmd.Flags().String("expires", "", "Set shared link expiration time as an RFC3339 timestamp")
shareLinkCreateCmd.Flags().Bool("remove-expiration", false, "Remove expiration when returning an existing shared link")
addSharedLinkPasswordFlags(shareLinkCreateCmd)
shareLinkCmd.AddCommand(shareLinkCreateCmd)
}
Loading