From e2e99da76052c6fcd90e1c5bde80134a25360ee3 Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Sun, 21 Jun 2026 09:47:54 -0700 Subject: [PATCH] Add --expires, --allow-download, and --remove-expiration flags to share-link create --- README.md | 3 + cmd/share_create_link_test.go | 279 ++++++++++++++++++++++++++++++++++ cmd/share_link_create.go | 97 ++++++++++++ 3 files changed, 379 insertions(+) diff --git a/README.md b/README.md index 9f29398..ad67bdc 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,9 @@ 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 --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 $ dbxcli share-link download [target] # download a shared-link file $ dbxcli share-link info # display shared link information $ dbxcli share-link list # list existing shared links diff --git a/cmd/share_create_link_test.go b/cmd/share_create_link_test.go index 4753763..f33bb35 100644 --- a/cmd/share_create_link_test.go +++ b/cmd/share_create_link_test.go @@ -162,6 +162,155 @@ func TestSharedLinkCreatePrintsURLAndUsesDefaultSettings(t *testing.T) { } } +func TestSharedLinkCreateWithExpiresSetsExpiration(t *testing.T) { + wantExpires := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + mock := &mockSharedLinkClient{ + createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) { + if arg.Settings == nil { + t.Fatal("settings = nil, want expiration settings") + } + if arg.Settings.Expires == nil { + t.Fatal("expires = nil, want expiration time") + } + if !arg.Settings.Expires.Equal(wantExpires) { + t.Fatalf("expires = %s, want %s", arg.Settings.Expires.Format(time.RFC3339), wantExpires.Format(time.RFC3339)) + } + return sharedLinkFile("/file.txt", "https://example.com/file"), nil + }, + } + stubSharedLinkClient(t, mock) + + var stdout bytes.Buffer + cmd := &cobra.Command{} + cmd.Flags().String("expires", "", "") + cmd.SetOut(&stdout) + if err := cmd.Flags().Set("expires", wantExpires.Format(time.RFC3339)); err != nil { + t.Fatalf("set expires: %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 TestSharedLinkCreateWithAllowDownloadSetsAllowDownload(t *testing.T) { + mock := &mockSharedLinkClient{ + createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) { + if arg.Settings == nil { + t.Fatal("settings = nil, want allow-download settings") + } + if !arg.Settings.AllowDownload { + t.Fatal("AllowDownload = false, want true") + } + return sharedLinkFile("/file.txt", "https://example.com/file"), nil + }, + } + stubSharedLinkClient(t, mock) + + var stdout bytes.Buffer + cmd := &cobra.Command{} + cmd.Flags().Bool("allow-download", false, "") + cmd.SetOut(&stdout) + if err := cmd.Flags().Set("allow-download", "true"); err != nil { + t.Fatalf("set allow-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 TestSharedLinkCreateWithInvalidExpiresReturnsError(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("expires", "", "") + if err := cmd.Flags().Set("expires", "tomorrow"); err != nil { + t.Fatalf("set expires: %v", err) + } + + err := shareLinkCreate(cmd, []string{"/file.txt"}) + if err == nil || !strings.Contains(err.Error(), `invalid --expires "tomorrow": use RFC3339 timestamp`) { + t.Fatalf("error = %v, want invalid expires error", err) + } + if called { + t.Fatal("CreateSharedLinkWithSettings should not be called") + } +} + +func TestSharedLinkCreateRejectsExpiresWithRemoveExpiration(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("expires", "", "") + cmd.Flags().Bool("remove-expiration", false, "") + if err := cmd.Flags().Set("expires", "2026-07-01T00:00:00Z"); err != nil { + t.Fatalf("set expires: %v", err) + } + if err := cmd.Flags().Set("remove-expiration", "true"); err != nil { + t.Fatalf("set remove-expiration: %v", err) + } + + err := shareLinkCreate(cmd, []string{"/file.txt"}) + if err == nil || !strings.Contains(err.Error(), "`--expires` and `--remove-expiration` cannot be used together") { + t.Fatalf("error = %v, want mutually exclusive error", err) + } + if called { + t.Fatal("CreateSharedLinkWithSettings should not be called") + } +} + +func TestSharedLinkCreateWithRemoveExpirationUsesDefaultCreateSettings(t *testing.T) { + mock := &mockSharedLinkClient{ + createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) { + if arg.Settings != nil { + t.Fatalf("settings = %#v, want nil for new link with remove-expiration", arg.Settings) + } + return sharedLinkFile("/file.txt", "https://example.com/file"), nil + }, + modifySharedLinkSettingsFn: func(arg *sharing.ModifySharedLinkSettingsArgs) (sharing.IsSharedLinkMetadata, error) { + t.Fatal("ModifySharedLinkSettings should not be called for a newly created link") + return nil, nil + }, + } + stubSharedLinkClient(t, mock) + + var stdout bytes.Buffer + cmd := &cobra.Command{} + cmd.Flags().Bool("remove-expiration", false, "") + cmd.SetOut(&stdout) + if err := cmd.Flags().Set("remove-expiration", "true"); err != nil { + t.Fatalf("set remove-expiration: %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 TestSharedLinkCreateVerboseStillPrintsURLOnly(t *testing.T) { mock := &mockSharedLinkClient{ createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) { @@ -283,6 +432,127 @@ func TestSharedLinkCreateVerboseReportsExistingLinkOnStderr(t *testing.T) { } } +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") + mock := &mockSharedLinkClient{ + createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) { + if arg.Settings == nil || arg.Settings.Expires == nil || !arg.Settings.Expires.Equal(wantExpires) { + t.Fatalf("create settings = %#v, want expires %s", arg.Settings, wantExpires.Format(time.RFC3339)) + } + 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.RemoveExpiration { + t.Fatal("RemoveExpiration = true, want false") + } + if arg.Settings == nil || arg.Settings.Expires == nil || !arg.Settings.Expires.Equal(wantExpires) { + t.Fatalf("modify settings = %#v, want expires %s", arg.Settings, wantExpires.Format(time.RFC3339)) + } + return sharedLinkFile("/file.txt", "https://example.com/file-new"), nil + }, + } + stubSharedLinkClient(t, mock) + + var stdout bytes.Buffer + cmd := &cobra.Command{} + cmd.Flags().String("expires", "", "") + cmd.SetOut(&stdout) + if err := cmd.Flags().Set("expires", wantExpires.Format(time.RFC3339)); err != nil { + t.Fatalf("set expires: %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 TestSharedLinkCreateWithRemoveExpirationUpdatesExistingLink(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 { + t.Fatalf("create settings = %#v, want nil", 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.RemoveExpiration { + t.Fatal("RemoveExpiration = false, want true") + } + if arg.Settings == nil { + t.Fatal("settings = nil, want empty settings object") + } + return sharedLinkFile("/file.txt", "https://example.com/file-new"), nil + }, + } + stubSharedLinkClient(t, mock) + + var stdout bytes.Buffer + cmd := &cobra.Command{} + cmd.Flags().Bool("remove-expiration", false, "") + cmd.SetOut(&stdout) + if err := cmd.Flags().Set("remove-expiration", "true"); err != nil { + t.Fatalf("set remove-expiration: %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 TestSharedLinkCreateWithAllowDownloadUpdatesExistingLink(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.AllowDownload { + t.Fatalf("create settings = %#v, want allow download", 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.RemoveExpiration { + t.Fatal("RemoveExpiration = true, want false") + } + if arg.Settings == nil || !arg.Settings.AllowDownload { + t.Fatalf("modify settings = %#v, want allow download", arg.Settings) + } + return sharedLinkFile("/file.txt", "https://example.com/file-new"), nil + }, + } + stubSharedLinkClient(t, mock) + + var stdout bytes.Buffer + cmd := &cobra.Command{} + cmd.Flags().Bool("allow-download", false, "") + cmd.SetOut(&stdout) + if err := cmd.Flags().Set("allow-download", "true"); err != nil { + t.Fatalf("set allow-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-new\n" { + t.Fatalf("stdout = %q, want updated URL", got) + } +} + func TestSharedLinkCreateFallbackPrefersExactPathLower(t *testing.T) { var listArg *sharing.ListSharedLinksArg mock := &mockSharedLinkClient{ @@ -402,6 +672,15 @@ func TestShareLinkCreateDoesNotBreakShareListLinkCommand(t *testing.T) { if cmd != shareLinkCreateCmd { t.Fatalf("share-link create resolved to %q", cmd.CommandPath()) } + if shareLinkCreateCmd.Flags().Lookup("allow-download") == nil { + t.Fatal("share-link create should define --allow-download") + } + if shareLinkCreateCmd.Flags().Lookup("expires") == nil { + t.Fatal("share-link create should define --expires") + } + if shareLinkCreateCmd.Flags().Lookup("remove-expiration") == nil { + t.Fatal("share-link create should define --remove-expiration") + } cmd, _, err = RootCmd.Find([]string{"share", "list", "link"}) if err != nil { diff --git a/cmd/share_link_create.go b/cmd/share_link_create.go index 8c939dc..5f94aec 100644 --- a/cmd/share_link_create.go +++ b/cmd/share_link_create.go @@ -19,11 +19,18 @@ import ( "fmt" "io" "strings" + "time" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/sharing" "github.com/spf13/cobra" ) +type shareLinkCreateOptions struct { + expires *time.Time + removeExpiration bool + allowDownload bool +} + func shareLinkCreate(cmd *cobra.Command, args []string) error { if len(args) != 1 { return errors.New("`share-link create` requires a `path` argument") @@ -37,8 +44,17 @@ func shareLinkCreate(cmd *cobra.Command, args []string) error { return errors.New("cannot create a shared link for Dropbox root") } + opts, err := parseShareLinkCreateOptions(cmd) + if err != nil { + return err + } + dbx := newSharedLinkClient(config) arg := sharing.NewCreateSharedLinkWithSettingsArg(path) + if opts.expires != nil || opts.allowDownload { + arg.Settings = sharing.NewSharedLinkSettings() + applySharedLinkCreateSettings(arg.Settings, opts) + } link, err := dbx.CreateSharedLinkWithSettings(arg) usedExisting := false if err != nil { @@ -46,6 +62,10 @@ func shareLinkCreate(cmd *cobra.Command, args []string) error { if err != nil { return err } + link, err = applyExistingSharedLinkCreateOptions(dbx, link, opts) + if err != nil { + return err + } usedExisting = true } @@ -67,6 +87,68 @@ func shareLinkCreate(cmd *cobra.Command, args []string) error { }) } +func parseShareLinkCreateOptions(cmd *cobra.Command) (shareLinkCreateOptions, error) { + var opts shareLinkCreateOptions + + if cmd.Flags().Changed("expires") { + expires, err := shareLinkExpiresFlag(cmd) + if err != nil { + return opts, err + } + opts.expires = expires + } + + if cmd.Flags().Changed("remove-expiration") { + removeExpiration, err := cmd.Flags().GetBool("remove-expiration") + if err != nil { + return opts, err + } + opts.removeExpiration = removeExpiration + } + + if cmd.Flags().Changed("allow-download") { + allowDownload, err := cmd.Flags().GetBool("allow-download") + if err != nil { + return opts, err + } + opts.allowDownload = allowDownload + } + + if opts.expires != nil && opts.removeExpiration { + return opts, errors.New("`--expires` and `--remove-expiration` cannot be used together") + } + + return opts, nil +} + +func applyExistingSharedLinkCreateOptions(dbx sharedLinkClient, link sharing.IsSharedLinkMetadata, opts shareLinkCreateOptions) (sharing.IsSharedLinkMetadata, error) { + if opts.expires == nil && !opts.removeExpiration && !opts.allowDownload { + return link, nil + } + + url, ok := sharedLinkURL(link) + if !ok { + return nil, errors.New("existing shared link response did not include a URL") + } + + settings := sharing.NewSharedLinkSettings() + applySharedLinkCreateSettings(settings, opts) + + arg := sharing.NewModifySharedLinkSettingsArgs(url, settings) + arg.RemoveExpiration = opts.removeExpiration + + return dbx.ModifySharedLinkSettings(arg) +} + +func applySharedLinkCreateSettings(settings *sharing.SharedLinkSettings, opts shareLinkCreateOptions) { + if opts.expires != nil { + settings.Expires = opts.expires + } + if opts.allowDownload { + settings.AllowDownload = true + } +} + func existingSharedLink(dbx sharedLinkClient, path string, err error) (sharing.IsSharedLinkMetadata, error) { apiErr, ok := createSharedLinkWithSettingsAPIError(err) if !ok || apiErr.EndpointError == nil || @@ -179,6 +261,18 @@ func sameDropboxPath(a string, b string) bool { return strings.EqualFold(cleanDropboxPath(a), cleanDropboxPath(b)) } +func shareLinkExpiresFlag(cmd *cobra.Command) (*time.Time, error) { + value, err := cmd.Flags().GetString("expires") + if err != nil { + return nil, err + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return nil, fmt.Errorf("invalid --expires %q: use RFC3339 timestamp", value) + } + return &parsed, nil +} + var shareLinkCreateCmd = &cobra.Command{ Use: "create ", Short: "Create a shared link", @@ -186,5 +280,8 @@ var shareLinkCreateCmd = &cobra.Command{ } 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") shareLinkCmd.AddCommand(shareLinkCreateCmd) }