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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ All `--sort`, `--reverse`, `--time`, and `--time-format` flags work with both `l
```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
$ 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 --remove-expiration # remove expiration when returning an existing link
Expand All @@ -239,11 +240,13 @@ $ 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 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.

Expand Down
98 changes: 98 additions & 0 deletions cmd/share_create_link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,40 @@ func TestSharedLinkCreateWithAccessSetsAccess(t *testing.T) {
}
}

func TestSharedLinkCreateWithAudienceSetsAudience(t *testing.T) {
mock := &mockSharedLinkClient{
createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) {
if arg.Settings == nil {
t.Fatal("settings = nil, want audience settings")
}
if arg.Settings.Audience == nil {
t.Fatal("audience = nil, want team")
}
if arg.Settings.Audience.Tag != sharing.LinkAudienceTeam {
t.Fatalf("audience = %q, want team", arg.Settings.Audience.Tag)
}
return sharedLinkFile("/file.txt", "https://example.com/file"), nil
},
}
stubSharedLinkClient(t, mock)

var stdout bytes.Buffer
cmd := &cobra.Command{}
cmd.Flags().String("audience", "", "")
cmd.SetOut(&stdout)
if err := cmd.Flags().Set("audience", "team"); err != nil {
t.Fatalf("set audience: %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 TestSharedLinkCreateWithInvalidExpiresReturnsError(t *testing.T) {
called := false
stubSharedLinkClient(t, &mockSharedLinkClient{
Expand Down Expand Up @@ -310,6 +344,30 @@ func TestSharedLinkCreateWithInvalidAccessReturnsError(t *testing.T) {
}
}

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

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

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

func TestSharedLinkCreateRejectsExpiresWithRemoveExpiration(t *testing.T) {
called := false
stubSharedLinkClient(t, &mockSharedLinkClient{
Expand Down Expand Up @@ -523,6 +581,43 @@ func TestSharedLinkCreateWithAccessErrorsForExistingLink(t *testing.T) {
}
}

func TestSharedLinkCreateWithAudienceUpdatesExistingLink(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.Audience == nil || arg.Settings.Audience.Tag != sharing.LinkAudienceMembers {
t.Fatalf("create settings = %#v, want members audience", 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.Audience == nil || arg.Settings.Audience.Tag != sharing.LinkAudienceMembers {
t.Fatalf("modify settings = %#v, want members audience", arg.Settings)
}
return sharedLinkFile("/file.txt", "https://example.com/file-new"), nil
},
}
stubSharedLinkClient(t, mock)

var stdout bytes.Buffer
cmd := &cobra.Command{}
cmd.Flags().String("audience", "", "")
cmd.SetOut(&stdout)
if err := cmd.Flags().Set("audience", "members"); err != nil {
t.Fatalf("set audience: %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 TestSharedLinkCreateWithExpiresUpdatesExistingLink(t *testing.T) {
wantExpires := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
existing := sharedLinkFile("/file.txt", "https://example.com/file-old")
Expand Down Expand Up @@ -766,6 +861,9 @@ func TestShareLinkCreateDoesNotBreakShareListLinkCommand(t *testing.T) {
if shareLinkCreateCmd.Flags().Lookup("access") == nil {
t.Fatal("share-link create should define --access")
}
if shareLinkCreateCmd.Flags().Lookup("audience") == nil {
t.Fatal("share-link create should define --audience")
}
if shareLinkCreateCmd.Flags().Lookup("allow-download") == nil {
t.Fatal("share-link create should define --allow-download")
}
Expand Down
40 changes: 38 additions & 2 deletions cmd/share_link_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type shareLinkCreateOptions struct {
removeExpiration bool
allowDownload bool
access *sharing.RequestedLinkAccessLevel
audience *sharing.LinkAudience
}

func shareLinkCreate(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -124,6 +125,14 @@ func parseShareLinkCreateOptions(cmd *cobra.Command) (shareLinkCreateOptions, er
opts.access = access
}

if cmd.Flags().Changed("audience") {
audience, err := shareLinkAudienceFlag(cmd)
if err != nil {
return opts, err
}
opts.audience = audience
}

if opts.expires != nil && opts.removeExpiration {
return opts, errors.New("`--expires` and `--remove-expiration` cannot be used together")
}
Expand All @@ -135,7 +144,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 {
if opts.expires == nil && !opts.removeExpiration && !opts.allowDownload && opts.audience == nil {
return link, nil
}

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

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

func applySharedLinkCreateSettings(settings *sharing.SharedLinkSettings, opts shareLinkCreateOptions) {
Expand All @@ -167,6 +176,9 @@ func applySharedLinkCreateSettings(settings *sharing.SharedLinkSettings, opts sh
if opts.access != nil {
settings.Access = opts.access
}
if opts.audience != nil {
settings.Audience = opts.audience
}
}

func existingSharedLink(dbx sharedLinkClient, path string, err error) (sharing.IsSharedLinkMetadata, error) {
Expand Down Expand Up @@ -314,6 +326,29 @@ func requestedLinkAccessLevel(tag string) *sharing.RequestedLinkAccessLevel {
return &sharing.RequestedLinkAccessLevel{Tagged: dropbox.Tagged{Tag: tag}}
}

func shareLinkAudienceFlag(cmd *cobra.Command) (*sharing.LinkAudience, error) {
value, err := cmd.Flags().GetString("audience")
if err != nil {
return nil, err
}
switch value {
case sharing.LinkAudiencePublic:
return linkAudience(sharing.LinkAudiencePublic), nil
case sharing.LinkAudienceTeam:
return linkAudience(sharing.LinkAudienceTeam), nil
case sharing.LinkAudienceMembers:
return linkAudience(sharing.LinkAudienceMembers), nil
case "no-one":
return linkAudience(sharing.LinkAudienceNoOne), nil
default:
return nil, fmt.Errorf("invalid --audience %q: use public, team, members, or no-one", value)
}
}

func linkAudience(tag string) *sharing.LinkAudience {
return &sharing.LinkAudience{Tagged: dropbox.Tagged{Tag: tag}}
}

var shareLinkCreateCmd = &cobra.Command{
Use: "create <path>",
Short: "Create a shared link",
Expand All @@ -322,6 +357,7 @@ var shareLinkCreateCmd = &cobra.Command{

func init() {
shareLinkCreateCmd.Flags().String("access", "", "Set shared link access level: viewer, editor, or max")
shareLinkCreateCmd.Flags().String("audience", "", "Set shared link audience: public, team, members, or no-one")
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")
Expand Down
18 changes: 17 additions & 1 deletion cmd/share_link_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type shareLinkUpdateOptions struct {
expires *time.Time
removeExpiration bool
allowDownload bool
audience *sharing.LinkAudience
}

func shareLinkUpdate(cmd *cobra.Command, args []string) error {
Expand All @@ -51,6 +52,9 @@ func shareLinkUpdate(cmd *cobra.Command, args []string) error {
if opts.allowDownload {
settings.AllowDownload = true
}
if opts.audience != nil {
settings.Audience = opts.audience
}

arg := sharing.NewModifySharedLinkSettingsArgs(url, settings)
arg.RemoveExpiration = opts.removeExpiration
Expand All @@ -75,11 +79,12 @@ func parseShareLinkUpdateOptions(cmd *cobra.Command) (shareLinkUpdateOptions, er
if err != nil {
return shareLinkUpdateOptions{}, err
}
audienceChanged := cmd.Flags().Changed("audience")

if expiresChanged && removeExpiration {
return shareLinkUpdateOptions{}, errors.New("`--expires` and `--remove-expiration` cannot be used together")
}
if !expiresChanged && !removeExpiration && !allowDownload {
if !expiresChanged && !removeExpiration && !allowDownload && !audienceChanged {
return shareLinkUpdateOptions{}, errors.New("at least one shared link setting flag is required")
}

Expand All @@ -96,10 +101,20 @@ func parseShareLinkUpdateOptions(cmd *cobra.Command) (shareLinkUpdateOptions, er
expires = &parsed
}

var audience *sharing.LinkAudience
if audienceChanged {
parsed, err := shareLinkAudienceFlag(cmd)
if err != nil {
return shareLinkUpdateOptions{}, err
}
audience = parsed
}

return shareLinkUpdateOptions{
expires: expires,
removeExpiration: removeExpiration,
allowDownload: allowDownload,
audience: audience,
}, nil
}

Expand All @@ -110,6 +125,7 @@ var shareLinkUpdateCmd = &cobra.Command{
}

func init() {
shareLinkUpdateCmd.Flags().String("audience", "", "Set shared link audience: public, team, members, or no-one")
shareLinkUpdateCmd.Flags().String("expires", "", "Set shared link expiration time as an RFC3339 timestamp")
shareLinkUpdateCmd.Flags().Bool("remove-expiration", false, "Remove the shared link expiration time")
shareLinkUpdateCmd.Flags().Bool("allow-download", false, "Allow downloads from the shared link")
Expand Down
57 changes: 57 additions & 0 deletions cmd/share_link_update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,29 @@ func TestShareLinkUpdateRejectsInvalidExpires(t *testing.T) {
}
}

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

cmd := newShareLinkUpdateTestCommand(nil, nil)
if err := cmd.Flags().Set("audience", "password"); err != nil {
t.Fatalf("set audience: %v", err)
}

err := shareLinkUpdate(cmd, []string{"https://example.com/link"})
if err == nil || !strings.Contains(err.Error(), `invalid --audience "password": use public, team, members, or no-one`) {
t.Fatalf("error = %v, want invalid audience error", err)
}
if called {
t.Fatal("ModifySharedLinkSettings should not be called")
}
}

func TestShareLinkUpdateRejectsExpiresAndRemoveExpiration(t *testing.T) {
called := false
stubSharedLinkClient(t, &mockSharedLinkClient{
Expand Down Expand Up @@ -228,6 +251,36 @@ func TestShareLinkUpdateAllowsDownload(t *testing.T) {
}
}

func TestShareLinkUpdateSetsAudience(t *testing.T) {
mock := &mockSharedLinkClient{
modifySharedLinkSettingsFn: func(arg *sharing.ModifySharedLinkSettingsArgs) (sharing.IsSharedLinkMetadata, error) {
if arg.Url != "https://example.com/link" {
t.Fatalf("url = %q, want https://example.com/link", arg.Url)
}
if arg.Settings == nil || arg.Settings.Audience == nil {
t.Fatal("audience setting was not sent")
}
if arg.Settings.Audience.Tag != sharing.LinkAudienceNoOne {
t.Fatalf("audience = %q, want no_one", arg.Settings.Audience.Tag)
}
if arg.RemoveExpiration {
t.Fatal("remove expiration = true, want false")
}
return sharedLinkFile("/file.txt", "https://example.com/link"), nil
},
}
stubSharedLinkClient(t, mock)

cmd := newShareLinkUpdateTestCommand(nil, nil)
if err := cmd.Flags().Set("audience", "no-one"); err != nil {
t.Fatalf("set audience: %v", err)
}

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

func TestShareLinkUpdateVerboseWritesStatusToStderr(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
Expand Down Expand Up @@ -283,10 +336,14 @@ func TestShareLinkUpdateCommandIsRegistered(t *testing.T) {
if cmd != shareLinkUpdateCmd {
t.Fatalf("share-link update resolved to %q", cmd.CommandPath())
}
if shareLinkUpdateCmd.Flags().Lookup("audience") == nil {
t.Fatal("share-link update should define --audience")
}
}

func newShareLinkUpdateTestCommand(stdout, stderr *bytes.Buffer) *cobra.Command {
cmd := &cobra.Command{}
cmd.Flags().String("audience", "", "")
cmd.Flags().String("expires", "", "")
cmd.Flags().Bool("remove-expiration", false, "")
cmd.Flags().Bool("allow-download", false, "")
Expand Down