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 @@ -229,6 +229,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 --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 @@ -242,6 +243,8 @@ $ 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 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.

New and changed commands should write command results to stdout. Status, progress, warnings, diagnostics, and verbose logs should go to stderr.
Expand Down
94 changes: 94 additions & 0 deletions cmd/share_create_link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,40 @@ func TestSharedLinkCreateWithAllowDownloadSetsAllowDownload(t *testing.T) {
}
}

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

var stdout bytes.Buffer
cmd := &cobra.Command{}
cmd.Flags().String("access", "", "")
cmd.SetOut(&stdout)
if err := cmd.Flags().Set("access", "editor"); err != nil {
t.Fatalf("set access: %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 All @@ -252,6 +286,30 @@ func TestSharedLinkCreateWithInvalidExpiresReturnsError(t *testing.T) {
}
}

func TestSharedLinkCreateWithInvalidAccessReturnsError(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("access", "", "")
if err := cmd.Flags().Set("access", "owner"); err != nil {
t.Fatalf("set access: %v", err)
}

err := shareLinkCreate(cmd, []string{"/file.txt"})
if err == nil || !strings.Contains(err.Error(), `invalid --access "owner": use viewer, editor, or max`) {
t.Fatalf("error = %v, want invalid access 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 @@ -432,6 +490,39 @@ func TestSharedLinkCreateVerboseReportsExistingLinkOnStderr(t *testing.T) {
}
}

func TestSharedLinkCreateWithAccessErrorsForExistingLink(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.Access == nil || arg.Settings.Access.Tag != sharing.RequestedLinkAccessLevelMax {
t.Fatalf("create settings = %#v, want max access", arg.Settings)
}
return nil, alreadyExistsError(existing)
},
modifySharedLinkSettingsFn: func(arg *sharing.ModifySharedLinkSettingsArgs) (sharing.IsSharedLinkMetadata, error) {
t.Fatal("ModifySharedLinkSettings should not be called because access cannot be modified")
return nil, nil
},
}
stubSharedLinkClient(t, mock)

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

err := shareLinkCreate(cmd, []string{"/file.txt"})
if err == nil || !strings.Contains(err.Error(), "cannot apply `--access` because the shared link already exists") {
t.Fatalf("error = %v, want existing link access error", err)
}
if got := stdout.String(); got != "" {
t.Fatalf("stdout = %q, want empty output on error", 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 @@ -672,6 +763,9 @@ func TestShareLinkCreateDoesNotBreakShareListLinkCommand(t *testing.T) {
if cmd != shareLinkCreateCmd {
t.Fatalf("share-link create resolved to %q", cmd.CommandPath())
}
if shareLinkCreateCmd.Flags().Lookup("access") == nil {
t.Fatal("share-link create should define --access")
}
if shareLinkCreateCmd.Flags().Lookup("allow-download") == nil {
t.Fatal("share-link create should define --allow-download")
}
Expand Down
44 changes: 43 additions & 1 deletion cmd/share_link_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"strings"
"time"

"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox"
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/sharing"
"github.com/spf13/cobra"
)
Expand All @@ -29,6 +30,7 @@ type shareLinkCreateOptions struct {
expires *time.Time
removeExpiration bool
allowDownload bool
access *sharing.RequestedLinkAccessLevel
}

func shareLinkCreate(cmd *cobra.Command, args []string) error {
Expand All @@ -51,7 +53,7 @@ func shareLinkCreate(cmd *cobra.Command, args []string) error {

dbx := newSharedLinkClient(config)
arg := sharing.NewCreateSharedLinkWithSettingsArg(path)
if opts.expires != nil || opts.allowDownload {
if opts.hasCreateSettings() {
arg.Settings = sharing.NewSharedLinkSettings()
applySharedLinkCreateSettings(arg.Settings, opts)
}
Expand Down Expand Up @@ -114,6 +116,14 @@ func parseShareLinkCreateOptions(cmd *cobra.Command) (shareLinkCreateOptions, er
opts.allowDownload = allowDownload
}

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

if opts.expires != nil && opts.removeExpiration {
return opts, errors.New("`--expires` and `--remove-expiration` cannot be used together")
}
Expand All @@ -122,6 +132,9 @@ func parseShareLinkCreateOptions(cmd *cobra.Command) (shareLinkCreateOptions, er
}

func applyExistingSharedLinkCreateOptions(dbx sharedLinkClient, link sharing.IsSharedLinkMetadata, opts shareLinkCreateOptions) (sharing.IsSharedLinkMetadata, error) {
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 {
return link, nil
}
Expand All @@ -140,13 +153,20 @@ func applyExistingSharedLinkCreateOptions(dbx sharedLinkClient, link sharing.IsS
return dbx.ModifySharedLinkSettings(arg)
}

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

func applySharedLinkCreateSettings(settings *sharing.SharedLinkSettings, opts shareLinkCreateOptions) {
if opts.expires != nil {
settings.Expires = opts.expires
}
if opts.allowDownload {
settings.AllowDownload = true
}
if opts.access != nil {
settings.Access = opts.access
}
}

func existingSharedLink(dbx sharedLinkClient, path string, err error) (sharing.IsSharedLinkMetadata, error) {
Expand Down Expand Up @@ -273,13 +293,35 @@ func shareLinkExpiresFlag(cmd *cobra.Command) (*time.Time, error) {
return &parsed, nil
}

func shareLinkAccessFlag(cmd *cobra.Command) (*sharing.RequestedLinkAccessLevel, error) {
value, err := cmd.Flags().GetString("access")
if err != nil {
return nil, err
}
switch value {
case sharing.RequestedLinkAccessLevelViewer:
return requestedLinkAccessLevel(sharing.RequestedLinkAccessLevelViewer), nil
case sharing.RequestedLinkAccessLevelEditor:
return requestedLinkAccessLevel(sharing.RequestedLinkAccessLevelEditor), nil
case sharing.RequestedLinkAccessLevelMax:
return requestedLinkAccessLevel(sharing.RequestedLinkAccessLevelMax), nil
default:
return nil, fmt.Errorf("invalid --access %q: use viewer, editor, or max", value)
}
}

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

var shareLinkCreateCmd = &cobra.Command{
Use: "create <path>",
Short: "Create a shared link",
RunE: shareLinkCreate,
}

func init() {
shareLinkCreateCmd.Flags().String("access", "", "Set shared link access level: viewer, editor, or max")
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