diff --git a/README.md b/README.md index ddd0180..8daa7d7 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 # revoke a shared link $ dbxcli share-link update --allow-download # update shared link settings +$ dbxcli share-link update --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. diff --git a/cmd/share_create_link_test.go b/cmd/share_create_link_test.go index bf980a8..d4857a7 100644 --- a/cmd/share_create_link_test.go +++ b/cmd/share_create_link_test.go @@ -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{ @@ -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{ @@ -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") @@ -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") } diff --git a/cmd/share_link_create.go b/cmd/share_link_create.go index 8a82ad6..0420028 100644 --- a/cmd/share_link_create.go +++ b/cmd/share_link_create.go @@ -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 { @@ -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") } @@ -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 } @@ -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) { @@ -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) { @@ -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 ", Short: "Create a shared link", @@ -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") diff --git a/cmd/share_link_update.go b/cmd/share_link_update.go index 29d50c4..eed2d1b 100644 --- a/cmd/share_link_update.go +++ b/cmd/share_link_update.go @@ -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 { @@ -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 @@ -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") } @@ -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 } @@ -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") diff --git a/cmd/share_link_update_test.go b/cmd/share_link_update_test.go index 137c305..10df2c0 100644 --- a/cmd/share_link_update_test.go +++ b/cmd/share_link_update_test.go @@ -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{ @@ -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 @@ -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, "")