diff --git a/README.md b/README.md index 55ae531..278e15d 100644 --- a/README.md +++ b/README.md @@ -232,17 +232,22 @@ $ 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 --disallow-download # create a shared link with downloads disabled $ 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 $ 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 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 +$ dbxcli share-link update --expires 2026-07-01T00:00:00Z # update shared link expiration +$ 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 $ dbxcli share list link # deprecated compatibility command @@ -250,9 +255,10 @@ $ 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 create`, `share-link update`, 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 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. diff --git a/cmd/share_create_link_test.go b/cmd/share_create_link_test.go index c4a5641..8af907d 100644 --- a/cmd/share_create_link_test.go +++ b/cmd/share_create_link_test.go @@ -29,13 +29,14 @@ import ( ) type mockSharedLinkClient struct { - createSharedLinkWithSettingsFn func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) - getSharedLinkFileFn func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) - 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 + createSharedLinkWithSettingsFn func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) + createSharedLinkWithRawSettingsFn func(path string, settings *rawSharedLinkSettings) (sharing.IsSharedLinkMetadata, error) + getSharedLinkFileFn func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) + getSharedLinkMetadataFn func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) + listSharedLinksFn func(arg *sharing.ListSharedLinksArg) (*sharing.ListSharedLinksResult, error) + modifySharedLinkSettingsFn func(arg *sharing.ModifySharedLinkSettingsArgs) (sharing.IsSharedLinkMetadata, error) + revokeSharedLinkFn func(arg *sharing.RevokeSharedLinkArg) error + modifySharedLinkSettingsRawFn func(url string, settings *rawSharedLinkSettings, removeExpiration bool) error } func (m *mockSharedLinkClient) CreateSharedLinkWithSettings(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) { @@ -45,6 +46,13 @@ func (m *mockSharedLinkClient) CreateSharedLinkWithSettings(arg *sharing.CreateS return nil, nil } +func (m *mockSharedLinkClient) CreateSharedLinkWithRawSettings(path string, settings *rawSharedLinkSettings) (sharing.IsSharedLinkMetadata, error) { + if m.createSharedLinkWithRawSettingsFn != nil { + return m.createSharedLinkWithRawSettingsFn(path, settings) + } + return nil, nil +} + func (m *mockSharedLinkClient) GetSharedLinkFile(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) { if m.getSharedLinkFileFn != nil { return m.getSharedLinkFileFn(arg) @@ -73,16 +81,16 @@ 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) +func (m *mockSharedLinkClient) RevokeSharedLink(arg *sharing.RevokeSharedLinkArg) error { + if m.revokeSharedLinkFn != nil { + return m.revokeSharedLinkFn(arg) } return nil } -func (m *mockSharedLinkClient) RevokeSharedLink(arg *sharing.RevokeSharedLinkArg) error { - if m.revokeSharedLinkFn != nil { - return m.revokeSharedLinkFn(arg) +func (m *mockSharedLinkClient) ModifySharedLinkSettingsRaw(url string, settings *rawSharedLinkSettings, removeExpiration bool) error { + if m.modifySharedLinkSettingsRawFn != nil { + return m.modifySharedLinkSettingsRawFn(url, settings, removeExpiration) } return nil } @@ -236,6 +244,137 @@ func TestSharedLinkCreateWithAllowDownloadSetsAllowDownload(t *testing.T) { } } +func TestSharedLinkCreateWithDisallowDownloadUpdatesNewLink(t *testing.T) { + mock := &mockSharedLinkClient{ + createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) { + t.Fatal("CreateSharedLinkWithSettings should not be called for disallow-download") + return nil, nil + }, + createSharedLinkWithRawSettingsFn: func(path string, settings *rawSharedLinkSettings) (sharing.IsSharedLinkMetadata, error) { + if path != "/file.txt" { + t.Fatalf("path = %q, want /file.txt", path) + } + if settings == nil || settings.AllowDownload == nil { + t.Fatalf("settings = %#v, want allow_download setting", settings) + } + if *settings.AllowDownload { + t.Fatal("allow_download = true, want false") + } + return sharedLinkFile("/file.txt", "https://example.com/file"), nil + }, + } + stubSharedLinkClient(t, mock) + + var stdout bytes.Buffer + cmd := &cobra.Command{} + cmd.Flags().Bool("disallow-download", false, "") + cmd.SetOut(&stdout) + if err := cmd.Flags().Set("disallow-download", "true"); err != nil { + t.Fatalf("set disallow-download: %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 TestSharedLinkCreateWithDisallowDownloadCombinesRawCreateSettings(t *testing.T) { + expires := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + mock := &mockSharedLinkClient{ + createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) { + t.Fatal("CreateSharedLinkWithSettings should not be called for disallow-download") + return nil, nil + }, + createSharedLinkWithRawSettingsFn: func(path string, settings *rawSharedLinkSettings) (sharing.IsSharedLinkMetadata, error) { + if path != "/file.txt" { + t.Fatalf("path = %q, want /file.txt", path) + } + if settings == nil { + t.Fatal("settings = nil, want raw settings") + } + if settings.AllowDownload == nil || *settings.AllowDownload { + t.Fatalf("allow_download = %v, want false", settings.AllowDownload) + } + if settings.Expires == nil || !settings.Expires.Equal(expires) { + t.Fatalf("expires = %v, want %v", settings.Expires, expires) + } + if settings.Access == nil || settings.Access.Tag != sharing.RequestedLinkAccessLevelViewer { + t.Fatalf("access = %#v, want viewer", settings.Access) + } + if settings.Audience == nil || settings.Audience.Tag != sharing.LinkAudienceTeam { + t.Fatalf("audience = %#v, want team", settings.Audience) + } + if settings.RequirePassword == nil || !*settings.RequirePassword || settings.LinkPassword != "secret" { + t.Fatalf("password settings = require:%v value:%q, want password settings", settings.RequirePassword, settings.LinkPassword) + } + return sharedLinkFile("/file.txt", "https://example.com/file"), nil + }, + } + stubSharedLinkClient(t, mock) + + var stdout bytes.Buffer + cmd := &cobra.Command{} + cmd.Flags().Bool("disallow-download", false, "") + cmd.Flags().String("expires", "", "") + cmd.Flags().String("access", "", "") + cmd.Flags().String("audience", "", "") + addSharedLinkPasswordFlags(cmd) + cmd.SetOut(&stdout) + if err := cmd.Flags().Set("disallow-download", "true"); err != nil { + t.Fatalf("set disallow-download: %v", err) + } + if err := cmd.Flags().Set("expires", expires.Format(time.RFC3339)); err != nil { + t.Fatalf("set expires: %v", err) + } + if err := cmd.Flags().Set("access", "viewer"); err != nil { + t.Fatalf("set access: %v", err) + } + if err := cmd.Flags().Set("audience", "team"); err != nil { + t.Fatalf("set audience: %v", err) + } + 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 TestSharedLinkCreateRejectsAllowAndDisallowDownload(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().Bool("allow-download", false, "") + cmd.Flags().Bool("disallow-download", false, "") + if err := cmd.Flags().Set("allow-download", "true"); err != nil { + t.Fatalf("set allow-download: %v", err) + } + if err := cmd.Flags().Set("disallow-download", "true"); err != nil { + t.Fatalf("set disallow-download: %v", err) + } + + err := shareLinkCreate(cmd, []string{"/file.txt"}) + if err == nil || !strings.Contains(err.Error(), "cannot be used together") { + t.Fatalf("error = %v, want mutual exclusion error", err) + } + if called { + t.Fatal("CreateSharedLinkWithSettings should not be called") + } +} + func TestSharedLinkCreateWithPasswordSetsPassword(t *testing.T) { mock := &mockSharedLinkClient{ createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) { @@ -864,6 +1003,65 @@ func TestSharedLinkCreateWithAllowDownloadUpdatesExistingLink(t *testing.T) { } } +func TestSharedLinkCreateWithDisallowDownloadUpdatesExistingLink(t *testing.T) { + existing := sharedLinkFile("/file.txt", "https://example.com/file-old") + var rawURL string + mock := &mockSharedLinkClient{ + createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) { + t.Fatal("CreateSharedLinkWithSettings should not be called for disallow-download") + return nil, nil + }, + createSharedLinkWithRawSettingsFn: func(path string, settings *rawSharedLinkSettings) (sharing.IsSharedLinkMetadata, error) { + if path != "/file.txt" { + t.Fatalf("path = %q, want /file.txt", path) + } + if settings == nil || settings.AllowDownload == nil { + t.Fatalf("create settings = %#v, want allow_download setting", settings) + } + if *settings.AllowDownload { + t.Fatal("create allow_download = true, want false") + } + return nil, alreadyExistsError(existing) + }, + modifySharedLinkSettingsFn: func(arg *sharing.ModifySharedLinkSettingsArgs) (sharing.IsSharedLinkMetadata, error) { + t.Fatal("ModifySharedLinkSettings should not be called for disallow-download only") + return nil, nil + }, + modifySharedLinkSettingsRawFn: func(url string, settings *rawSharedLinkSettings, removeExpiration bool) error { + rawURL = url + if removeExpiration { + t.Fatal("remove expiration = true, want false") + } + if settings == nil || settings.AllowDownload == nil { + t.Fatalf("settings = %#v, want allow_download setting", settings) + } + if *settings.AllowDownload { + t.Fatal("allow_download = true, want false") + } + return nil + }, + } + stubSharedLinkClient(t, mock) + + var stdout bytes.Buffer + cmd := &cobra.Command{} + cmd.Flags().Bool("disallow-download", false, "") + cmd.SetOut(&stdout) + if err := cmd.Flags().Set("disallow-download", "true"); err != nil { + t.Fatalf("set disallow-download: %v", err) + } + + if err := shareLinkCreate(cmd, []string{"/file.txt"}); err != nil { + t.Fatalf("shareLinkCreate error: %v", err) + } + if rawURL != "https://example.com/file-old" { + t.Fatalf("raw modify URL = %q, want existing URL", rawURL) + } + if got := stdout.String(); got != "https://example.com/file-old\n" { + t.Fatalf("stdout = %q, want existing URL", got) + } +} + func TestSharedLinkCreateWithPasswordUpdatesExistingLink(t *testing.T) { existing := sharedLinkFile("/file.txt", "https://example.com/file-old") mock := &mockSharedLinkClient{ @@ -1029,6 +1227,9 @@ func TestShareLinkCreateDoesNotBreakShareListLinkCommand(t *testing.T) { if shareLinkCreateCmd.Flags().Lookup("allow-download") == nil { t.Fatal("share-link create should define --allow-download") } + if shareLinkCreateCmd.Flags().Lookup("disallow-download") == nil { + t.Fatal("share-link create should define --disallow-download") + } if shareLinkCreateCmd.Flags().Lookup("expires") == nil { t.Fatal("share-link create should define --expires") } diff --git a/cmd/share_link.go b/cmd/share_link.go index 05fc081..0de2df5 100644 --- a/cmd/share_link.go +++ b/cmd/share_link.go @@ -24,12 +24,13 @@ import ( type sharedLinkClient interface { CreateSharedLinkWithSettings(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) + CreateSharedLinkWithRawSettings(path string, settings *rawSharedLinkSettings) (sharing.IsSharedLinkMetadata, error) GetSharedLinkFile(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) 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 + ModifySharedLinkSettingsRaw(url string, settings *rawSharedLinkSettings, removeExpiration bool) error } type sdkSharedLinkClient struct { diff --git a/cmd/share_link_create.go b/cmd/share_link_create.go index bf33873..e742933 100644 --- a/cmd/share_link_create.go +++ b/cmd/share_link_create.go @@ -30,6 +30,7 @@ type shareLinkCreateOptions struct { expires *time.Time removeExpiration bool allowDownload bool + disallowDownload bool access *sharing.RequestedLinkAccessLevel audience *sharing.LinkAudience password sharedLinkPasswordOptions @@ -54,12 +55,7 @@ func shareLinkCreate(cmd *cobra.Command, args []string) error { } dbx := newSharedLinkClient(config) - arg := sharing.NewCreateSharedLinkWithSettingsArg(path) - if opts.hasCreateSettings() { - arg.Settings = sharing.NewSharedLinkSettings() - applySharedLinkCreateSettings(arg.Settings, opts) - } - link, err := dbx.CreateSharedLinkWithSettings(arg) + link, err := createSharedLink(dbx, path, opts) usedExisting := false if err != nil { link, err = existingSharedLink(dbx, path, err) @@ -91,6 +87,19 @@ func shareLinkCreate(cmd *cobra.Command, args []string) error { }) } +func createSharedLink(dbx sharedLinkClient, path string, opts shareLinkCreateOptions) (sharing.IsSharedLinkMetadata, error) { + if opts.disallowDownload { + return dbx.CreateSharedLinkWithRawSettings(path, rawSharedLinkSettingsFromCreateOptions(opts)) + } + + arg := sharing.NewCreateSharedLinkWithSettingsArg(path) + if opts.hasCreateSettings() { + arg.Settings = sharing.NewSharedLinkSettings() + applySharedLinkCreateSettings(arg.Settings, opts) + } + return dbx.CreateSharedLinkWithSettings(arg) +} + func parseShareLinkCreateOptions(cmd *cobra.Command) (shareLinkCreateOptions, error) { var opts shareLinkCreateOptions @@ -118,6 +127,14 @@ func parseShareLinkCreateOptions(cmd *cobra.Command) (shareLinkCreateOptions, er opts.allowDownload = allowDownload } + if cmd.Flags().Changed("disallow-download") { + disallowDownload, err := cmd.Flags().GetBool("disallow-download") + if err != nil { + return opts, err + } + opts.disallowDownload = disallowDownload + } + if cmd.Flags().Changed("access") { access, err := shareLinkAccessFlag(cmd) if err != nil { @@ -143,6 +160,9 @@ func parseShareLinkCreateOptions(cmd *cobra.Command) (shareLinkCreateOptions, er if opts.expires != nil && opts.removeExpiration { return opts, errors.New("`--expires` and `--remove-expiration` cannot be used together") } + if opts.allowDownload && opts.disallowDownload { + return opts, errors.New("`--allow-download` and `--disallow-download` cannot be used together") + } return opts, nil } @@ -151,7 +171,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 && !opts.password.set { + if opts.expires == nil && !opts.removeExpiration && !opts.allowDownload && !opts.disallowDownload && opts.audience == nil && !opts.password.set { return link, nil } @@ -160,13 +180,28 @@ func applyExistingSharedLinkCreateOptions(dbx sharedLinkClient, link sharing.IsS return nil, errors.New("existing shared link response did not include a URL") } - settings := sharing.NewSharedLinkSettings() - applySharedLinkCreateSettings(settings, opts) + if opts.disallowDownload { + if err := dbx.ModifySharedLinkSettingsRaw(url, rawSharedLinkSettingsFromCreateOptions(opts), opts.removeExpiration); err != nil { + return nil, err + } + return link, nil + } - arg := sharing.NewModifySharedLinkSettingsArgs(url, settings) - arg.RemoveExpiration = opts.removeExpiration + if opts.expires != nil || opts.removeExpiration || opts.allowDownload || opts.audience != nil || opts.password.set { + settings := sharing.NewSharedLinkSettings() + applySharedLinkCreateSettings(settings, opts) + + arg := sharing.NewModifySharedLinkSettingsArgs(url, settings) + arg.RemoveExpiration = opts.removeExpiration + + updated, err := dbx.ModifySharedLinkSettings(arg) + if err != nil { + return nil, err + } + link = updated + } - return dbx.ModifySharedLinkSettings(arg) + return link, nil } func (opts shareLinkCreateOptions) hasCreateSettings() bool { @@ -192,6 +227,24 @@ func applySharedLinkCreateSettings(settings *sharing.SharedLinkSettings, opts sh } } +func rawSharedLinkSettingsFromCreateOptions(opts shareLinkCreateOptions) *rawSharedLinkSettings { + settings := &rawSharedLinkSettings{ + Expires: opts.expires, + Audience: opts.audience, + Access: opts.access, + } + if opts.allowDownload || opts.disallowDownload { + allowDownload := opts.allowDownload + settings.AllowDownload = &allowDownload + } + if opts.password.set { + requirePassword := true + settings.RequirePassword = &requirePassword + settings.LinkPassword = opts.password.password + } + return settings +} + func existingSharedLink(dbx sharedLinkClient, path string, err error) (sharing.IsSharedLinkMetadata, error) { apiErr, ok := createSharedLinkWithSettingsAPIError(err) if !ok || apiErr.EndpointError == nil || @@ -370,6 +423,7 @@ 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().Bool("disallow-download", false, "Disallow 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) diff --git a/cmd/share_link_info.go b/cmd/share_link_info.go index 0051d5f..6d48d6d 100644 --- a/cmd/share_link_info.go +++ b/cmd/share_link_info.go @@ -26,6 +26,11 @@ import ( "github.com/spf13/cobra" ) +type shareLinkInfoOptions struct { + path string + password sharedLinkPasswordOptions +} + func shareLinkInfo(cmd *cobra.Command, args []string) error { if len(args) != 1 { return errors.New("`share-link info` requires a `url` argument") @@ -36,8 +41,20 @@ func shareLinkInfo(cmd *cobra.Command, args []string) error { return errors.New("`share-link info` requires a non-empty URL") } + opts, err := parseShareLinkInfoOptions(cmd) + if err != nil { + return err + } + dbx := newSharedLinkClient(config) arg := sharing.NewGetSharedLinkMetadataArg(url) + if opts.path != "" { + arg.Path = sharedLinkAPIPath(opts.path) + } + if opts.password.set { + arg.LinkPassword = opts.password.password + } + link, err := dbx.GetSharedLinkMetadata(arg) if err != nil { return err @@ -48,6 +65,29 @@ func shareLinkInfo(cmd *cobra.Command, args []string) error { }) } +func parseShareLinkInfoOptions(cmd *cobra.Command) (shareLinkInfoOptions, error) { + var opts shareLinkInfoOptions + + if localFlagChanged(cmd, "path") { + path, err := localStringFlag(cmd, "path") + if err != nil { + return opts, err + } + if path == "" { + return opts, errors.New("`--path` requires a non-empty path") + } + opts.path = path + } + + password, err := sharedLinkPasswordFromFlags(cmd) + if err != nil { + return opts, err + } + opts.password = password + + return opts, nil +} + func renderSharedLinkInfo(out io.Writer, link sharing.IsSharedLinkMetadata) error { metadata, linkType, ok := sharedLinkBaseMetadata(link) if !ok { @@ -119,5 +159,7 @@ var shareLinkInfoCmd = &cobra.Command{ } func init() { + shareLinkInfoCmd.Flags().String("path", "", "Display metadata for a path inside the shared link") + addSharedLinkPasswordFlags(shareLinkInfoCmd) shareLinkCmd.AddCommand(shareLinkInfoCmd) } diff --git a/cmd/share_link_info_test.go b/cmd/share_link_info_test.go index 27a0f4f..c009f90 100644 --- a/cmd/share_link_info_test.go +++ b/cmd/share_link_info_test.go @@ -115,6 +115,63 @@ func TestShareLinkInfoCallsAPIWithURLAndPrintsFileInfo(t *testing.T) { } } +func TestShareLinkInfoPassesPathAndPassword(t *testing.T) { + var requestedPath string + var requestedPassword string + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + requestedPath = arg.Path + requestedPassword = arg.LinkPassword + return sharedLinkFile("/docs/report.txt", "https://www.dropbox.com/s/abc123"), nil + }, + }) + + var stdout bytes.Buffer + cmd := newShareLinkInfoTestCommand(&stdout) + if err := cmd.Flags().Set("path", "docs/report.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 := shareLinkInfo(cmd, []string{"https://www.dropbox.com/s/abc123"}); err != nil { + t.Fatalf("shareLinkInfo error: %v", err) + } + if requestedPath != "/docs/report.txt" { + t.Fatalf("path = %q, want /docs/report.txt", requestedPath) + } + if requestedPassword != "secret" { + t.Fatalf("password = %q, want secret", requestedPassword) + } + if !strings.Contains(stdout.String(), "URL:") { + t.Fatalf("stdout = %q, want rendered metadata", stdout.String()) + } +} + +func TestShareLinkInfoRejectsEmptyPathFlag(t *testing.T) { + called := false + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + called = true + return nil, nil + }, + }) + + cmd := newShareLinkInfoTestCommand(nil) + if err := cmd.Flags().Set("path", ""); err != nil { + t.Fatalf("set path: %v", err) + } + + err := shareLinkInfo(cmd, []string{"https://www.dropbox.com/s/abc123"}) + if err == nil || !strings.Contains(err.Error(), "`--path` requires a non-empty path") { + t.Fatalf("error = %v, want empty path error", err) + } + if called { + t.Fatal("GetSharedLinkMetadata should not be called") + } +} + func TestShareLinkInfoPrintsFolderInfo(t *testing.T) { link := sharedLinkFolder("/docs", "https://www.dropbox.com/s/folder") link.Id = "id:folder123" @@ -169,6 +226,18 @@ func TestShareLinkInfoDoesNotBreakOtherCommands(t *testing.T) { if cmd != shareLinkInfoCmd { t.Fatalf("share-link info resolved to %q", cmd.CommandPath()) } + if shareLinkInfoCmd.Flags().Lookup("path") == nil { + t.Fatal("share-link info should define --path") + } + if shareLinkInfoCmd.Flags().Lookup("password") == nil { + t.Fatal("share-link info should define --password") + } + if shareLinkInfoCmd.Flags().Lookup("password-prompt") == nil { + t.Fatal("share-link info should define --password-prompt") + } + if shareLinkInfoCmd.Flags().Lookup("password-file") == nil { + t.Fatal("share-link info should define --password-file") + } cmd, _, err = RootCmd.Find([]string{"share-link", "create"}) if err != nil { @@ -202,3 +271,13 @@ func TestShareLinkInfoDoesNotBreakOtherCommands(t *testing.T) { t.Fatalf("share-link download resolved to %q", cmd.CommandPath()) } } + +func newShareLinkInfoTestCommand(stdout *bytes.Buffer) *cobra.Command { + cmd := &cobra.Command{} + cmd.Flags().String("path", "", "") + addSharedLinkPasswordFlags(cmd) + if stdout != nil { + cmd.SetOut(stdout) + } + return cmd +} diff --git a/cmd/share_link_password.go b/cmd/share_link_password.go index 79496fa..5a6dd5e 100644 --- a/cmd/share_link_password.go +++ b/cmd/share_link_password.go @@ -22,9 +22,6 @@ import ( "os" "strings" - "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" - "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/auth" - "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/sharing" "github.com/spf13/cobra" "golang.org/x/term" ) @@ -143,48 +140,3 @@ func localStringFlag(cmd *cobra.Command, name string) (string, error) { } return cmd.Flags().GetString(name) } - -type removeSharedLinkPasswordArgs struct { - URL string `json:"url"` - Settings *removeSharedLinkPasswordSettings `json:"settings"` - RemoveExpiration bool `json:"remove_expiration"` -} - -type removeSharedLinkPasswordSettings struct { - RequirePassword bool `json:"require_password"` -} - -func (dbx *sdkSharedLinkClient) RemoveSharedLinkPassword(url string) error { - arg := &removeSharedLinkPasswordArgs{ - URL: url, - Settings: &removeSharedLinkPasswordSettings{ - RequirePassword: false, - }, - RemoveExpiration: false, - } - req := dropbox.Request{ - Host: "api", - Namespace: "sharing", - Route: "modify_shared_link_settings", - Auth: "user", - Style: "rpc", - Arg: arg, - } - - ctx := dropbox.NewContext(dbx.cfg) - resp, respBody, err := (&ctx).Execute(req, nil) - if err != nil { - var appErr sharing.ModifySharedLinkSettingsAPIError - err = auth.ParseError(err, &appErr) - if err == &appErr { - return appErr - } - return err - } - if respBody != nil { - _ = respBody.Close() - } - - _ = resp - return nil -} diff --git a/cmd/share_link_password_test.go b/cmd/share_link_password_test.go deleted file mode 100644 index c70dca0..0000000 --- a/cmd/share_link_password_test.go +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright © 2016 Dropbox, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cmd - -import ( - "encoding/json" - "io" - "net/http" - "strings" - "testing" - - "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" -) - -type roundTripFunc func(*http.Request) (*http.Response, error) - -func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return f(req) -} - -func TestRemoveSharedLinkPasswordSendsRequirePasswordFalse(t *testing.T) { - var body map[string]any - httpClient := &http.Client{ - Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.URL.String() != "https://api.dropboxapi.com/2/sharing/modify_shared_link_settings" { - t.Fatalf("url = %q, want modify_shared_link_settings route", req.URL.String()) - } - if err := json.NewDecoder(req.Body).Decode(&body); err != nil { - t.Fatalf("decode request body: %v", err) - } - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{".tag":"file"}`)), - Header: make(http.Header), - }, nil - }), - } - dbx := &sdkSharedLinkClient{ - cfg: dropbox.Config{ - Token: "token", - Client: httpClient, - }, - } - - if err := dbx.RemoveSharedLinkPassword("https://example.com/link"); err != nil { - t.Fatalf("RemoveSharedLinkPassword error: %v", err) - } - - if body["url"] != "https://example.com/link" { - t.Fatalf("url = %q, want https://example.com/link", body["url"]) - } - settings, ok := body["settings"].(map[string]any) - if !ok { - t.Fatalf("settings = %#v, want object", body["settings"]) - } - requirePassword, ok := settings["require_password"].(bool) - if !ok { - t.Fatalf("require_password = %#v, want bool", settings["require_password"]) - } - if requirePassword { - t.Fatal("require_password = true, want false") - } -} diff --git a/cmd/share_link_raw.go b/cmd/share_link_raw.go new file mode 100644 index 0000000..fdd4e02 --- /dev/null +++ b/cmd/share_link_raw.go @@ -0,0 +1,150 @@ +// Copyright © 2016 Dropbox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "time" + + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/auth" + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/sharing" +) + +type rawCreateSharedLinkWithSettingsArg struct { + Path string `json:"path"` + Settings *rawSharedLinkSettings `json:"settings,omitempty"` +} + +type rawModifySharedLinkSettingsArgs struct { + URL string `json:"url"` + Settings *rawSharedLinkSettings `json:"settings"` + RemoveExpiration bool `json:"remove_expiration"` +} + +type rawSharedLinkSettings struct { + RequirePassword *bool `json:"require_password,omitempty"` + LinkPassword string `json:"link_password,omitempty"` + Expires *time.Time `json:"expires,omitempty"` + Audience *sharing.LinkAudience `json:"audience,omitempty"` + Access *sharing.RequestedLinkAccessLevel `json:"access,omitempty"` + AllowDownload *bool `json:"allow_download,omitempty"` +} + +func (dbx *sdkSharedLinkClient) CreateSharedLinkWithRawSettings(path string, settings *rawSharedLinkSettings) (sharing.IsSharedLinkMetadata, error) { + arg := &rawCreateSharedLinkWithSettingsArg{ + Path: path, + Settings: settings, + } + req := dropbox.Request{ + Host: "api", + Namespace: "sharing", + Route: "create_shared_link_with_settings", + Auth: "user", + Style: "rpc", + Arg: arg, + } + + resp, respBody, err := executeSharingRawRequest(dbx.cfg, req, parseCreateSharedLinkWithSettingsError) + if err != nil { + return nil, err + } + if respBody != nil { + _ = respBody.Close() + } + + return parseSharedLinkMetadata(resp) +} + +func (dbx *sdkSharedLinkClient) ModifySharedLinkSettingsRaw(url string, settings *rawSharedLinkSettings, removeExpiration bool) error { + arg := &rawModifySharedLinkSettingsArgs{ + URL: url, + Settings: settings, + RemoveExpiration: removeExpiration, + } + req := dropbox.Request{ + Host: "api", + Namespace: "sharing", + Route: "modify_shared_link_settings", + Auth: "user", + Style: "rpc", + Arg: arg, + } + + _, respBody, err := executeSharingRawRequest(dbx.cfg, req, parseModifySharedLinkSettingsError) + if err != nil { + return err + } + if respBody != nil { + _ = respBody.Close() + } + + return nil +} + +func executeSharingRawRequest(cfg dropbox.Config, req dropbox.Request, parseError func(error) error) ([]byte, io.ReadCloser, error) { + ctx := dropbox.NewContext(cfg) + resp, respBody, err := (&ctx).Execute(req, nil) + if err != nil { + return nil, nil, parseError(err) + } + return resp, respBody, nil +} + +func parseCreateSharedLinkWithSettingsError(err error) error { + var appErr sharing.CreateSharedLinkWithSettingsAPIError + parsed := auth.ParseError(err, &appErr) + if parsed == &appErr { + return appErr + } + return parsed +} + +func parseModifySharedLinkSettingsError(err error) error { + var appErr sharing.ModifySharedLinkSettingsAPIError + parsed := auth.ParseError(err, &appErr) + if parsed == &appErr { + return appErr + } + return parsed +} + +func parseSharedLinkMetadata(resp []byte) (sharing.IsSharedLinkMetadata, error) { + var tagged struct { + Tag string `json:".tag"` + } + if err := json.Unmarshal(resp, &tagged); err != nil { + return nil, err + } + + switch tagged.Tag { + case "file": + var file sharing.FileLinkMetadata + if err := json.Unmarshal(resp, &file); err != nil { + return nil, err + } + return &file, nil + case "folder": + var folder sharing.FolderLinkMetadata + if err := json.Unmarshal(resp, &folder); err != nil { + return nil, err + } + return &folder, nil + default: + return nil, fmt.Errorf("shared link response has unknown type %q", tagged.Tag) + } +} diff --git a/cmd/share_link_raw_test.go b/cmd/share_link_raw_test.go new file mode 100644 index 0000000..acc4089 --- /dev/null +++ b/cmd/share_link_raw_test.go @@ -0,0 +1,206 @@ +// Copyright © 2016 Dropbox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/sharing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestModifySharedLinkSettingsRawSendsRequirePasswordFalse(t *testing.T) { + var body map[string]any + httpClient := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.String() != "https://api.dropboxapi.com/2/sharing/modify_shared_link_settings" { + t.Fatalf("url = %q, want modify_shared_link_settings route", req.URL.String()) + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + t.Fatalf("decode request body: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{".tag":"file"}`)), + Header: make(http.Header), + }, nil + }), + } + dbx := &sdkSharedLinkClient{ + cfg: dropbox.Config{ + Token: "token", + Client: httpClient, + }, + } + + requirePassword := false + if err := dbx.ModifySharedLinkSettingsRaw("https://example.com/link", &rawSharedLinkSettings{ + RequirePassword: &requirePassword, + }, false); err != nil { + t.Fatalf("ModifySharedLinkSettingsRaw error: %v", err) + } + + if body["url"] != "https://example.com/link" { + t.Fatalf("url = %q, want https://example.com/link", body["url"]) + } + settings, ok := body["settings"].(map[string]any) + if !ok { + t.Fatalf("settings = %#v, want object", body["settings"]) + } + requirePasswordValue, ok := settings["require_password"].(bool) + if !ok { + t.Fatalf("require_password = %#v, want bool", settings["require_password"]) + } + if requirePasswordValue { + t.Fatal("require_password = true, want false") + } +} + +func TestCreateSharedLinkWithRawSettingsSendsAllowDownloadFalse(t *testing.T) { + var body map[string]any + httpClient := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.String() != "https://api.dropboxapi.com/2/sharing/create_shared_link_with_settings" { + t.Fatalf("url = %q, want create_shared_link_with_settings route", req.URL.String()) + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + t.Fatalf("decode request body: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{".tag":"file","url":"https://example.com/link","name":"file.txt"}`)), + Header: make(http.Header), + }, nil + }), + } + dbx := &sdkSharedLinkClient{ + cfg: dropbox.Config{ + Token: "token", + Client: httpClient, + }, + } + + allowDownload := false + link, err := dbx.CreateSharedLinkWithRawSettings("/file.txt", &rawSharedLinkSettings{ + AllowDownload: &allowDownload, + }) + if err != nil { + t.Fatalf("CreateSharedLinkWithRawSettings error: %v", err) + } + + if body["path"] != "/file.txt" { + t.Fatalf("path = %q, want /file.txt", body["path"]) + } + settings, ok := body["settings"].(map[string]any) + if !ok { + t.Fatalf("settings = %#v, want object", body["settings"]) + } + allowDownloadValue, ok := settings["allow_download"].(bool) + if !ok { + t.Fatalf("allow_download = %#v, want bool", settings["allow_download"]) + } + if allowDownloadValue { + t.Fatal("allow_download = true, want false") + } + url, ok := sharedLinkURL(link) + if !ok || url != "https://example.com/link" { + t.Fatalf("url = %q, %t, want https://example.com/link", url, ok) + } +} + +func TestModifySharedLinkSettingsRawSendsAllowDownloadFalse(t *testing.T) { + var body map[string]any + httpClient := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.String() != "https://api.dropboxapi.com/2/sharing/modify_shared_link_settings" { + t.Fatalf("url = %q, want modify_shared_link_settings route", req.URL.String()) + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + t.Fatalf("decode request body: %v", err) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{".tag":"file"}`)), + Header: make(http.Header), + }, nil + }), + } + dbx := &sdkSharedLinkClient{ + cfg: dropbox.Config{ + Token: "token", + Client: httpClient, + }, + } + + allowDownload := false + if err := dbx.ModifySharedLinkSettingsRaw("https://example.com/link", &rawSharedLinkSettings{ + AllowDownload: &allowDownload, + }, false); err != nil { + t.Fatalf("ModifySharedLinkSettingsRaw error: %v", err) + } + + if body["url"] != "https://example.com/link" { + t.Fatalf("url = %q, want https://example.com/link", body["url"]) + } + settings, ok := body["settings"].(map[string]any) + if !ok { + t.Fatalf("settings = %#v, want object", body["settings"]) + } + allowDownloadValue, ok := settings["allow_download"].(bool) + if !ok { + t.Fatalf("allow_download = %#v, want bool", settings["allow_download"]) + } + if allowDownloadValue { + t.Fatal("allow_download = true, want false") + } +} + +func TestRawCreateSharedLinkErrorParserReturnsValueError(t *testing.T) { + err := parseCreateSharedLinkWithSettingsError(dropbox.SDKInternalError{ + StatusCode: http.StatusConflict, + Content: `{ "error_summary": "shared_link_already_exists/", "error": { ".tag": "shared_link_already_exists" } }`, + }) + + if _, ok := err.(sharing.CreateSharedLinkWithSettingsAPIError); !ok { + t.Fatalf("error type = %T, want value CreateSharedLinkWithSettingsAPIError", err) + } + if _, ok := err.(*sharing.CreateSharedLinkWithSettingsAPIError); ok { + t.Fatalf("error type = %T, want non-pointer CreateSharedLinkWithSettingsAPIError", err) + } +} + +func TestRawModifySharedLinkErrorParserReturnsValueError(t *testing.T) { + err := parseModifySharedLinkSettingsError(dropbox.SDKInternalError{ + StatusCode: http.StatusConflict, + Content: `{ "error_summary": "settings_error/", "error": { ".tag": "settings_error" } }`, + }) + + if _, ok := err.(sharing.ModifySharedLinkSettingsAPIError); !ok { + t.Fatalf("error type = %T, want value ModifySharedLinkSettingsAPIError", err) + } + if _, ok := err.(*sharing.ModifySharedLinkSettingsAPIError); ok { + t.Fatalf("error type = %T, want non-pointer ModifySharedLinkSettingsAPIError", err) + } +} diff --git a/cmd/share_link_update.go b/cmd/share_link_update.go index cf71f49..42bff24 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 + disallowDownload bool audience *sharing.LinkAudience password sharedLinkPasswordOptions removePassword bool @@ -47,6 +48,15 @@ func shareLinkUpdate(cmd *cobra.Command, args []string) error { return err } + dbx := newSharedLinkClient(config) + if opts.usesRawSettings() { + if err := dbx.ModifySharedLinkSettingsRaw(url, rawSharedLinkSettingsFromUpdateOptions(opts), opts.removeExpiration); err != nil { + return err + } + commandVerboseStatus(cmd, "Updated shared link %s", url) + return nil + } + settings := sharing.NewSharedLinkSettings() if opts.expires != nil { settings.Expires = opts.expires @@ -65,18 +75,11 @@ func shareLinkUpdate(cmd *cobra.Command, args []string) error { arg := sharing.NewModifySharedLinkSettingsArgs(url, settings) arg.RemoveExpiration = opts.removeExpiration - dbx := newSharedLinkClient(config) if opts.hasSDKSettings() { if _, err := dbx.ModifySharedLinkSettings(arg); err != nil { return err } } - if opts.removePassword { - if err := dbx.RemoveSharedLinkPassword(url); err != nil { - return err - } - } - commandVerboseStatus(cmd, "Updated shared link %s", url) return nil @@ -92,6 +95,10 @@ func parseShareLinkUpdateOptions(cmd *cobra.Command) (shareLinkUpdateOptions, er if err != nil { return shareLinkUpdateOptions{}, err } + disallowDownload, err := cmd.Flags().GetBool("disallow-download") + if err != nil { + return shareLinkUpdateOptions{}, err + } audienceChanged := cmd.Flags().Changed("audience") password, err := sharedLinkPasswordFromFlags(cmd) if err != nil { @@ -105,10 +112,13 @@ func parseShareLinkUpdateOptions(cmd *cobra.Command) (shareLinkUpdateOptions, er if expiresChanged && removeExpiration { return shareLinkUpdateOptions{}, errors.New("`--expires` and `--remove-expiration` cannot be used together") } + if allowDownload && disallowDownload { + return shareLinkUpdateOptions{}, errors.New("`--allow-download` and `--disallow-download` cannot be used together") + } if password.set && removePassword { return shareLinkUpdateOptions{}, errors.New("password-setting flags and `--remove-password` cannot be used together") } - if !expiresChanged && !removeExpiration && !allowDownload && !audienceChanged && !password.set && !removePassword { + if !expiresChanged && !removeExpiration && !allowDownload && !disallowDownload && !audienceChanged && !password.set && !removePassword { return shareLinkUpdateOptions{}, errors.New("at least one shared link setting flag is required") } @@ -138,6 +148,7 @@ func parseShareLinkUpdateOptions(cmd *cobra.Command) (shareLinkUpdateOptions, er expires: expires, removeExpiration: removeExpiration, allowDownload: allowDownload, + disallowDownload: disallowDownload, audience: audience, password: password, removePassword: removePassword, @@ -148,6 +159,31 @@ func (opts shareLinkUpdateOptions) hasSDKSettings() bool { return opts.expires != nil || opts.removeExpiration || opts.allowDownload || opts.audience != nil || opts.password.set } +func (opts shareLinkUpdateOptions) usesRawSettings() bool { + return opts.disallowDownload || opts.removePassword +} + +func rawSharedLinkSettingsFromUpdateOptions(opts shareLinkUpdateOptions) *rawSharedLinkSettings { + settings := &rawSharedLinkSettings{ + Expires: opts.expires, + Audience: opts.audience, + } + if opts.allowDownload || opts.disallowDownload { + allowDownload := opts.allowDownload + settings.AllowDownload = &allowDownload + } + if opts.password.set { + requirePassword := true + settings.RequirePassword = &requirePassword + settings.LinkPassword = opts.password.password + } + if opts.removePassword { + requirePassword := false + settings.RequirePassword = &requirePassword + } + return settings +} + var shareLinkUpdateCmd = &cobra.Command{ Use: "update ", Short: "Update shared link settings", @@ -159,6 +195,7 @@ func init() { 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") + shareLinkUpdateCmd.Flags().Bool("disallow-download", false, "Disallow downloads from the shared link") addSharedLinkPasswordFlags(shareLinkUpdateCmd) shareLinkUpdateCmd.Flags().Bool("remove-password", false, "Remove the shared link password") shareLinkCmd.AddCommand(shareLinkUpdateCmd) diff --git a/cmd/share_link_update_test.go b/cmd/share_link_update_test.go index fc74890..1491d5f 100644 --- a/cmd/share_link_update_test.go +++ b/cmd/share_link_update_test.go @@ -252,6 +252,127 @@ func TestShareLinkUpdateAllowsDownload(t *testing.T) { } } +func TestShareLinkUpdateDisallowsDownload(t *testing.T) { + var rawURL string + mock := &mockSharedLinkClient{ + modifySharedLinkSettingsFn: func(arg *sharing.ModifySharedLinkSettingsArgs) (sharing.IsSharedLinkMetadata, error) { + t.Fatal("ModifySharedLinkSettings should not be called for disallow-download only") + return nil, nil + }, + modifySharedLinkSettingsRawFn: func(url string, settings *rawSharedLinkSettings, removeExpiration bool) error { + rawURL = url + if removeExpiration { + t.Fatal("remove expiration = true, want false") + } + if settings == nil || settings.AllowDownload == nil { + t.Fatalf("settings = %#v, want allow_download setting", settings) + } + if *settings.AllowDownload { + t.Fatal("allow_download = true, want false") + } + return nil + }, + } + stubSharedLinkClient(t, mock) + + cmd := newShareLinkUpdateTestCommand(nil, nil) + if err := cmd.Flags().Set("disallow-download", "true"); err != nil { + t.Fatalf("set disallow-download: %v", err) + } + + if err := shareLinkUpdate(cmd, []string{"https://example.com/link"}); err != nil { + t.Fatalf("shareLinkUpdate error: %v", err) + } + if rawURL != "https://example.com/link" { + t.Fatalf("raw modify URL = %q, want https://example.com/link", rawURL) + } +} + +func TestShareLinkUpdateDisallowDownloadCombinesSettingsInOneRawCall(t *testing.T) { + expires := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + var rawCalls int + mock := &mockSharedLinkClient{ + modifySharedLinkSettingsFn: func(arg *sharing.ModifySharedLinkSettingsArgs) (sharing.IsSharedLinkMetadata, error) { + t.Fatal("ModifySharedLinkSettings should not be called when disallow-download is combined with other settings") + return nil, nil + }, + modifySharedLinkSettingsRawFn: func(url string, settings *rawSharedLinkSettings, removeExpiration bool) error { + rawCalls++ + if url != "https://example.com/link" { + t.Fatalf("url = %q, want https://example.com/link", url) + } + if removeExpiration { + t.Fatal("remove expiration = true, want false") + } + if settings == nil || settings.AllowDownload == nil || *settings.AllowDownload { + t.Fatalf("settings = %#v, want allow_download=false", settings) + } + if settings.Expires == nil || !settings.Expires.Equal(expires) { + t.Fatalf("expires = %v, want %v", settings.Expires, expires) + } + if settings.Audience == nil || settings.Audience.Tag != sharing.LinkAudienceTeam { + t.Fatalf("audience = %#v, want team", settings.Audience) + } + if settings.RequirePassword == nil || !*settings.RequirePassword || settings.LinkPassword != "secret" { + t.Fatalf("password settings = require:%v value:%q, want password settings", settings.RequirePassword, settings.LinkPassword) + } + return nil + }, + } + stubSharedLinkClient(t, mock) + + cmd := newShareLinkUpdateTestCommand(nil, nil) + if err := cmd.Flags().Set("disallow-download", "true"); err != nil { + t.Fatalf("set disallow-download: %v", err) + } + if err := cmd.Flags().Set("expires", expires.Format(time.RFC3339)); err != nil { + t.Fatalf("set expires: %v", err) + } + if err := cmd.Flags().Set("audience", "team"); err != nil { + t.Fatalf("set audience: %v", err) + } + if err := cmd.Flags().Set("password", "secret"); err != nil { + t.Fatalf("set password: %v", err) + } + + if err := shareLinkUpdate(cmd, []string{"https://example.com/link"}); err != nil { + t.Fatalf("shareLinkUpdate error: %v", err) + } + if rawCalls != 1 { + t.Fatalf("raw modify calls = %d, want 1", rawCalls) + } +} + +func TestShareLinkUpdateRejectsAllowAndDisallowDownload(t *testing.T) { + called := false + stubSharedLinkClient(t, &mockSharedLinkClient{ + modifySharedLinkSettingsFn: func(arg *sharing.ModifySharedLinkSettingsArgs) (sharing.IsSharedLinkMetadata, error) { + called = true + return nil, nil + }, + modifySharedLinkSettingsRawFn: func(url string, settings *rawSharedLinkSettings, removeExpiration bool) error { + called = true + return nil + }, + }) + + cmd := newShareLinkUpdateTestCommand(nil, nil) + if err := cmd.Flags().Set("allow-download", "true"); err != nil { + t.Fatalf("set allow-download: %v", err) + } + if err := cmd.Flags().Set("disallow-download", "true"); err != nil { + t.Fatalf("set disallow-download: %v", err) + } + + err := shareLinkUpdate(cmd, []string{"https://example.com/link"}) + if err == nil || !strings.Contains(err.Error(), "cannot be used together") { + t.Fatalf("error = %v, want mutual exclusion error", err) + } + if called { + t.Fatal("shared link API should not be called") + } +} + func TestShareLinkUpdateSetsPassword(t *testing.T) { mock := &mockSharedLinkClient{ modifySharedLinkSettingsFn: func(arg *sharing.ModifySharedLinkSettingsArgs) (sharing.IsSharedLinkMetadata, error) { @@ -263,10 +384,6 @@ func TestShareLinkUpdateSetsPassword(t *testing.T) { } return sharedLinkFile("/file.txt", "https://example.com/link"), nil }, - removeSharedLinkPasswordFn: func(url string) error { - t.Fatal("RemoveSharedLinkPassword should not be called") - return nil - }, } stubSharedLinkClient(t, mock) @@ -307,14 +424,72 @@ func TestShareLinkUpdateReadsPasswordFile(t *testing.T) { } func TestShareLinkUpdateRemovesPassword(t *testing.T) { - var removedURL string + var rawURL string mock := &mockSharedLinkClient{ modifySharedLinkSettingsFn: func(arg *sharing.ModifySharedLinkSettingsArgs) (sharing.IsSharedLinkMetadata, error) { t.Fatal("ModifySharedLinkSettings should not be called for remove-password only") return nil, nil }, - removeSharedLinkPasswordFn: func(url string) error { - removedURL = url + modifySharedLinkSettingsRawFn: func(url string, settings *rawSharedLinkSettings, removeExpiration bool) error { + rawURL = url + if removeExpiration { + t.Fatal("remove expiration = true, want false") + } + if settings == nil || settings.RequirePassword == nil { + t.Fatalf("settings = %#v, want require_password setting", settings) + } + if *settings.RequirePassword { + t.Fatal("require_password = true, want false") + } + return nil + }, + } + stubSharedLinkClient(t, mock) + + cmd := newShareLinkUpdateTestCommand(nil, nil) + if err := cmd.Flags().Set("remove-password", "true"); err != nil { + t.Fatalf("set remove-password: %v", err) + } + + if err := shareLinkUpdate(cmd, []string{"https://example.com/link"}); err != nil { + t.Fatalf("shareLinkUpdate error: %v", err) + } + if rawURL != "https://example.com/link" { + t.Fatalf("raw modify URL = %q, want https://example.com/link", rawURL) + } +} + +func TestShareLinkUpdateRemovePasswordCombinesSettingsInOneRawCall(t *testing.T) { + expires := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + var rawCalls int + mock := &mockSharedLinkClient{ + modifySharedLinkSettingsFn: func(arg *sharing.ModifySharedLinkSettingsArgs) (sharing.IsSharedLinkMetadata, error) { + t.Fatal("ModifySharedLinkSettings should not be called when remove-password is combined with other settings") + return nil, nil + }, + modifySharedLinkSettingsRawFn: func(url string, settings *rawSharedLinkSettings, removeExpiration bool) error { + rawCalls++ + if url != "https://example.com/link" { + t.Fatalf("url = %q, want https://example.com/link", url) + } + if removeExpiration { + t.Fatal("remove expiration = true, want false") + } + if settings == nil { + t.Fatal("settings = nil, want raw settings") + } + if settings.RequirePassword == nil || *settings.RequirePassword { + t.Fatalf("require_password = %v, want false", settings.RequirePassword) + } + if settings.AllowDownload == nil || !*settings.AllowDownload { + t.Fatalf("allow_download = %v, want true", settings.AllowDownload) + } + if settings.Expires == nil || !settings.Expires.Equal(expires) { + t.Fatalf("expires = %v, want %v", settings.Expires, expires) + } + if settings.Audience == nil || settings.Audience.Tag != sharing.LinkAudienceTeam { + t.Fatalf("audience = %#v, want team", settings.Audience) + } return nil }, } @@ -324,12 +499,21 @@ func TestShareLinkUpdateRemovesPassword(t *testing.T) { if err := cmd.Flags().Set("remove-password", "true"); err != nil { t.Fatalf("set remove-password: %v", err) } + if err := cmd.Flags().Set("allow-download", "true"); err != nil { + t.Fatalf("set allow-download: %v", err) + } + if err := cmd.Flags().Set("expires", expires.Format(time.RFC3339)); err != nil { + t.Fatalf("set expires: %v", err) + } + if err := cmd.Flags().Set("audience", "team"); 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) } - if removedURL != "https://example.com/link" { - t.Fatalf("removed URL = %q, want https://example.com/link", removedURL) + if rawCalls != 1 { + t.Fatalf("raw modify calls = %d, want 1", rawCalls) } } @@ -340,7 +524,7 @@ func TestShareLinkUpdateRejectsPasswordAndRemovePassword(t *testing.T) { called = true return nil, nil }, - removeSharedLinkPasswordFn: func(url string) error { + modifySharedLinkSettingsRawFn: func(url string, settings *rawSharedLinkSettings, removeExpiration bool) error { called = true return nil }, @@ -463,6 +647,9 @@ func TestShareLinkUpdateCommandIsRegistered(t *testing.T) { if shareLinkUpdateCmd.Flags().Lookup("remove-password") == nil { t.Fatal("share-link update should define --remove-password") } + if shareLinkUpdateCmd.Flags().Lookup("disallow-download") == nil { + t.Fatal("share-link update should define --disallow-download") + } } func newShareLinkUpdateTestCommand(stdout, stderr *bytes.Buffer) *cobra.Command { @@ -471,6 +658,7 @@ func newShareLinkUpdateTestCommand(stdout, stderr *bytes.Buffer) *cobra.Command cmd.Flags().String("expires", "", "") cmd.Flags().Bool("remove-expiration", false, "") cmd.Flags().Bool("allow-download", false, "") + cmd.Flags().Bool("disallow-download", false, "") addSharedLinkPasswordFlags(cmd) cmd.Flags().Bool("remove-password", false, "") cmd.Flags().Bool("verbose", false, "")