diff --git a/README.md b/README.md index 8daa7d7..55ae531 100644 --- a/README.md +++ b/README.md @@ -233,14 +233,18 @@ $ dbxcli share-link create /file.txt --access viewer # create a link with reques $ 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 --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 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-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 $ dbxcli share list folder # list shared folders ``` @@ -248,7 +252,9 @@ $ 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. +`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 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. New and changed commands should write command results to stdout. Status, progress, warnings, diagnostics, and verbose logs should go to stderr. diff --git a/cmd/share_create_link_test.go b/cmd/share_create_link_test.go index d4857a7..c4a5641 100644 --- a/cmd/share_create_link_test.go +++ b/cmd/share_create_link_test.go @@ -34,6 +34,7 @@ type mockSharedLinkClient struct { 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 } @@ -72,6 +73,13 @@ 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) + } + return nil +} + func (m *mockSharedLinkClient) RevokeSharedLink(arg *sharing.RevokeSharedLinkArg) error { if m.revokeSharedLinkFn != nil { return m.revokeSharedLinkFn(arg) @@ -228,6 +236,123 @@ func TestSharedLinkCreateWithAllowDownloadSetsAllowDownload(t *testing.T) { } } +func TestSharedLinkCreateWithPasswordSetsPassword(t *testing.T) { + mock := &mockSharedLinkClient{ + createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) { + if arg.Settings == nil { + t.Fatal("settings = nil, want password settings") + } + if !arg.Settings.RequirePassword { + t.Fatal("RequirePassword = false, want true") + } + if arg.Settings.LinkPassword != "secret" { + t.Fatalf("LinkPassword = %q, want secret", arg.Settings.LinkPassword) + } + return sharedLinkFile("/file.txt", "https://example.com/file"), nil + }, + } + stubSharedLinkClient(t, mock) + + var stdout bytes.Buffer + cmd := &cobra.Command{} + addSharedLinkPasswordFlags(cmd) + cmd.SetOut(&stdout) + 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 TestSharedLinkCreateWithPasswordPromptSetsPassword(t *testing.T) { + orig := readSharedLinkPassword + readSharedLinkPassword = func(prompt string, in io.Reader, errOut io.Writer) (string, error) { + if prompt != "Shared link password: " { + t.Fatalf("prompt = %q, want shared link password prompt", prompt) + } + return "prompt-secret", nil + } + t.Cleanup(func() { readSharedLinkPassword = orig }) + + mock := &mockSharedLinkClient{ + createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) { + if arg.Settings == nil || !arg.Settings.RequirePassword || arg.Settings.LinkPassword != "prompt-secret" { + t.Fatalf("settings = %#v, want prompted password", arg.Settings) + } + return sharedLinkFile("/file.txt", "https://example.com/file"), nil + }, + } + stubSharedLinkClient(t, mock) + + var stdout bytes.Buffer + cmd := &cobra.Command{} + addSharedLinkPasswordFlags(cmd) + cmd.SetOut(&stdout) + if err := cmd.Flags().Set("password-prompt", "true"); err != nil { + t.Fatalf("set password-prompt: %v", err) + } + + if err := shareLinkCreate(cmd, []string{"/file.txt"}); err != nil { + t.Fatalf("shareLinkCreate error: %v", err) + } +} + +func TestSharedLinkCreateRejectsMultiplePasswordSources(t *testing.T) { + called := false + stubSharedLinkClient(t, &mockSharedLinkClient{ + createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) { + called = true + return nil, nil + }, + }) + + cmd := &cobra.Command{} + addSharedLinkPasswordFlags(cmd) + if err := cmd.Flags().Set("password", "secret"); err != nil { + t.Fatalf("set password: %v", err) + } + if err := cmd.Flags().Set("password-prompt", "true"); err != nil { + t.Fatalf("set password-prompt: %v", err) + } + + err := shareLinkCreate(cmd, []string{"/file.txt"}) + if err == nil || !strings.Contains(err.Error(), "use only one of `--password`, `--password-prompt`, or `--password-file`") { + t.Fatalf("error = %v, want password source error", err) + } + if called { + t.Fatal("CreateSharedLinkWithSettings should not be called") + } +} + +func TestSharedLinkCreateRejectsEmptyPassword(t *testing.T) { + called := false + stubSharedLinkClient(t, &mockSharedLinkClient{ + createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) { + called = true + return nil, nil + }, + }) + + cmd := &cobra.Command{} + addSharedLinkPasswordFlags(cmd) + if err := cmd.Flags().Set("password", ""); err != nil { + t.Fatalf("set password: %v", err) + } + + err := shareLinkCreate(cmd, []string{"/file.txt"}) + if err == nil || !strings.Contains(err.Error(), "shared link password cannot be empty") { + t.Fatalf("error = %v, want empty password error", err) + } + if called { + t.Fatal("CreateSharedLinkWithSettings should not be called") + } +} + func TestSharedLinkCreateWithAccessSetsAccess(t *testing.T) { mock := &mockSharedLinkClient{ createSharedLinkWithSettingsFn: func(arg *sharing.CreateSharedLinkWithSettingsArg) (sharing.IsSharedLinkMetadata, error) { @@ -739,6 +864,43 @@ func TestSharedLinkCreateWithAllowDownloadUpdatesExistingLink(t *testing.T) { } } +func TestSharedLinkCreateWithPasswordUpdatesExistingLink(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.RequirePassword || arg.Settings.LinkPassword != "secret" { + t.Fatalf("create settings = %#v, want password settings", 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.RequirePassword || arg.Settings.LinkPassword != "secret" { + t.Fatalf("modify settings = %#v, want password settings", arg.Settings) + } + return sharedLinkFile("/file.txt", "https://example.com/file-new"), nil + }, + } + stubSharedLinkClient(t, mock) + + var stdout bytes.Buffer + cmd := &cobra.Command{} + addSharedLinkPasswordFlags(cmd) + cmd.SetOut(&stdout) + 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-new\n" { + t.Fatalf("stdout = %q, want updated URL", got) + } +} + func TestSharedLinkCreateFallbackPrefersExactPathLower(t *testing.T) { var listArg *sharing.ListSharedLinksArg mock := &mockSharedLinkClient{ @@ -873,6 +1035,15 @@ func TestShareLinkCreateDoesNotBreakShareListLinkCommand(t *testing.T) { if shareLinkCreateCmd.Flags().Lookup("remove-expiration") == nil { t.Fatal("share-link create should define --remove-expiration") } + if shareLinkCreateCmd.Flags().Lookup("password") == nil { + t.Fatal("share-link create should define --password") + } + if shareLinkCreateCmd.Flags().Lookup("password-prompt") == nil { + t.Fatal("share-link create should define --password-prompt") + } + if shareLinkCreateCmd.Flags().Lookup("password-file") == nil { + t.Fatal("share-link create should define --password-file") + } cmd, _, err = RootCmd.Find([]string{"share", "list", "link"}) if err != nil { diff --git a/cmd/share_link.go b/cmd/share_link.go index 34158e2..05fc081 100644 --- a/cmd/share_link.go +++ b/cmd/share_link.go @@ -28,11 +28,20 @@ type sharedLinkClient interface { 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 } +type sdkSharedLinkClient struct { + sharing.Client + cfg dropbox.Config +} + var newSharedLinkClient = func(cfg dropbox.Config) sharedLinkClient { - return sharing.New(cfg) + return &sdkSharedLinkClient{ + Client: sharing.New(cfg), + cfg: cfg, + } } var shareLinkCmd = &cobra.Command{ diff --git a/cmd/share_link_create.go b/cmd/share_link_create.go index 0420028..bf33873 100644 --- a/cmd/share_link_create.go +++ b/cmd/share_link_create.go @@ -32,6 +32,7 @@ type shareLinkCreateOptions struct { allowDownload bool access *sharing.RequestedLinkAccessLevel audience *sharing.LinkAudience + password sharedLinkPasswordOptions } func shareLinkCreate(cmd *cobra.Command, args []string) error { @@ -133,6 +134,12 @@ func parseShareLinkCreateOptions(cmd *cobra.Command) (shareLinkCreateOptions, er opts.audience = audience } + password, err := sharedLinkPasswordFromFlags(cmd) + if err != nil { + return opts, err + } + opts.password = password + if opts.expires != nil && opts.removeExpiration { return opts, errors.New("`--expires` and `--remove-expiration` cannot be used together") } @@ -144,7 +151,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 { + if opts.expires == nil && !opts.removeExpiration && !opts.allowDownload && opts.audience == nil && !opts.password.set { return link, nil } @@ -163,7 +170,7 @@ func applyExistingSharedLinkCreateOptions(dbx sharedLinkClient, link sharing.IsS } func (opts shareLinkCreateOptions) hasCreateSettings() bool { - return opts.expires != nil || opts.allowDownload || opts.access != nil || opts.audience != nil + return opts.expires != nil || opts.allowDownload || opts.access != nil || opts.audience != nil || opts.password.set } func applySharedLinkCreateSettings(settings *sharing.SharedLinkSettings, opts shareLinkCreateOptions) { @@ -179,6 +186,10 @@ func applySharedLinkCreateSettings(settings *sharing.SharedLinkSettings, opts sh if opts.audience != nil { settings.Audience = opts.audience } + if opts.password.set { + settings.RequirePassword = true + settings.LinkPassword = opts.password.password + } } func existingSharedLink(dbx sharedLinkClient, path string, err error) (sharing.IsSharedLinkMetadata, error) { @@ -361,5 +372,6 @@ 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") + addSharedLinkPasswordFlags(shareLinkCreateCmd) shareLinkCmd.AddCommand(shareLinkCreateCmd) } diff --git a/cmd/share_link_download.go b/cmd/share_link_download.go index a767d17..2408c1a 100644 --- a/cmd/share_link_download.go +++ b/cmd/share_link_download.go @@ -21,7 +21,9 @@ import ( "os" "path" "path/filepath" + "strings" + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/sharing" "github.com/dustin/go-humanize" "github.com/mitchellh/ioprogress" @@ -47,14 +49,48 @@ func shareLinkDownload(cmd *cobra.Command, args []string) error { } arg := sharing.NewGetSharedLinkMetadataArg(url) - password, err := cmd.Flags().GetString("password") + password, err := sharedLinkPasswordFromFlags(cmd) + if err != nil { + return err + } + if password.set { + arg.LinkPassword = password.password + } + + recursive, err := cmd.Flags().GetBool("recursive") if err != nil { return err } - arg.LinkPassword = password dbx := newSharedLinkClient(config) + link, err := dbx.GetSharedLinkMetadata(arg) + if err != nil { + return err + } + + if folder, ok := link.(*sharing.FolderLinkMetadata); ok { + if !recursive { + return errors.New("shared link is a folder (use --recursive to download folders)") + } + if target == "-" { + return errors.New("cannot download shared-link folder to stdout") + } + + dst, err := sharedLinkFolderDownloadTarget(target, folder) + if err != nil { + return err + } + if err := downloadSharedLinkFolder(filesNewFunc(config), dbx, arg, folder.Name, dst, cmd.ErrOrStderr()); err != nil { + return err + } + commandVerboseStatus(cmd, "Downloaded shared link folder to %s", dst) + return nil + } + if target == "-" { + if recursive { + return errors.New("`share-link download -` cannot be used with --recursive") + } if err := downloadSharedLinkToStdout(dbx, arg, cmd.OutOrStdout()); err != nil { return err } @@ -70,6 +106,191 @@ func shareLinkDownload(cmd *cobra.Command, args []string) error { return nil } +func sharedLinkFolderDownloadTarget(target string, link *sharing.FolderLinkMetadata) (string, error) { + name := link.Name + name = filepath.Base(filepath.FromSlash(name)) + if name == "" || name == "." || name == ".." || name == string(filepath.Separator) { + return "", errors.New("shared link folder metadata did not include a name") + } + + if target == "" { + return name, nil + } + + if info, err := os.Stat(target); err == nil && info.IsDir() { + return filepath.Join(target, name), nil + } else if err != nil && !os.IsNotExist(err) { + return "", err + } + + return target, nil +} + +func downloadSharedLinkFolder(filesDbx files.Client, dbx sharedLinkClient, arg *sharing.GetSharedLinkMetadataArg, rootName, dst string, errOut io.Writer) error { + if errOut == nil { + errOut = io.Discard + } + + var downloadErrors []error + queue := []string{""} + + for len(queue) > 0 { + relFolder := queue[0] + queue = queue[1:] + + entries, err := listSharedLinkFolderEntries(filesDbx, arg, relFolder) + if err != nil { + if relFolder == "" { + return err + } + downloadErrors = append(downloadErrors, err) + continue + } + if relFolder == "" { + if err := os.MkdirAll(dst, 0755); err != nil { + return err + } + } + + for _, entry := range entries { + switch f := entry.(type) { + case *files.FolderMetadata: + relPath, err := sharedLinkEntryRelativePath(f.PathDisplay, rootName) + if err != nil { + downloadErrors = append(downloadErrors, err) + continue + } + if relPath == "" { + continue + } + localDir, err := sharedLinkLocalPath(dst, relPath) + if err != nil { + downloadErrors = append(downloadErrors, err) + continue + } + if err := os.MkdirAll(localDir, 0755); err != nil { + downloadErrors = append(downloadErrors, fmt.Errorf("mkdir %s: %w", localDir, err)) + continue + } + queue = append(queue, relPath) + case *files.FileMetadata: + relPath, err := sharedLinkEntryRelativePath(f.PathDisplay, rootName) + if err != nil { + downloadErrors = append(downloadErrors, err) + continue + } + localPath, err := sharedLinkLocalPath(dst, relPath) + if err != nil { + downloadErrors = append(downloadErrors, err) + continue + } + if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil { + downloadErrors = append(downloadErrors, fmt.Errorf("mkdir %s: %w", filepath.Dir(localPath), err)) + continue + } + fmt.Fprintf(errOut, "Downloading %s -> %s\n", relPath, localPath) + if err := downloadSharedLinkRelativeFile(dbx, arg, relPath, localPath, errOut); err != nil { + downloadErrors = append(downloadErrors, fmt.Errorf("%s: %w", relPath, err)) + } + } + } + } + + if len(downloadErrors) > 0 { + for _, e := range downloadErrors { + fmt.Fprintf(errOut, "Error: %v\n", e) + } + return fmt.Errorf("share-link download: %d error(s)", len(downloadErrors)) + } + + return nil +} + +func listSharedLinkFolderEntries(dbx files.Client, arg *sharing.GetSharedLinkMetadataArg, relFolder string) ([]files.IsMetadata, error) { + listArg := files.NewListFolderArg(sharedLinkAPIPath(relFolder)) + listArg.SharedLink = files.NewSharedLink(arg.Url) + listArg.SharedLink.Password = arg.LinkPassword + + res, err := dbx.ListFolder(listArg) + if err != nil { + return nil, fmt.Errorf("list shared link folder %q: %v", relFolder, err) + } + + entries := append([]files.IsMetadata{}, res.Entries...) + for res.HasMore { + if res.Cursor == "" { + return entries, errors.New("list shared link folder has more results but no cursor") + } + cont := files.NewListFolderContinueArg(res.Cursor) + res, err = dbx.ListFolderContinue(cont) + if err != nil { + return entries, fmt.Errorf("list shared link folder continue: %v", err) + } + entries = append(entries, res.Entries...) + } + + return entries, nil +} + +func downloadSharedLinkRelativeFile(dbx sharedLinkClient, baseArg *sharing.GetSharedLinkMetadataArg, relPath, dst string, errOut io.Writer) error { + arg := sharing.NewGetSharedLinkMetadataArg(baseArg.Url) + arg.Path = sharedLinkAPIPath(relPath) + arg.LinkPassword = baseArg.LinkPassword + + return retryWithBackoff(func() error { + link, contents, err := dbx.GetSharedLinkFile(arg) + if err != nil { + return err + } + if contents == nil { + return errors.New("shared link download response did not include file content") + } + defer func() { _ = contents.Close() }() + + return copySharedLinkContentToFile(contents, sharedLinkDownloadSize(link), dst, errOut) + }) +} + +func sharedLinkEntryRelativePath(pathDisplay string, rootName string) (string, error) { + rel := strings.TrimPrefix(pathDisplay, "/") + if rootName != "" { + rootName = strings.Trim(rootName, "/") + first, rest, ok := strings.Cut(rel, "/") + if strings.EqualFold(first, rootName) { + if !ok { + return "", nil + } + rel = rest + } + } + parts := strings.Split(rel, "/") + for _, part := range parts { + if part == "" || part == "." || part == ".." { + return "", fmt.Errorf("invalid shared link entry path %q", pathDisplay) + } + } + rel = path.Clean(rel) + if rel == "" || rel == "." || rel == ".." || strings.HasPrefix(rel, "../") { + return "", fmt.Errorf("invalid shared link entry path %q", pathDisplay) + } + return rel, nil +} + +func sharedLinkAPIPath(rel string) string { + if rel == "" { + return "" + } + return "/" + strings.TrimPrefix(rel, "/") +} + +func sharedLinkLocalPath(root, rel string) (string, error) { + localRel := filepath.Clean(filepath.FromSlash(rel)) + if localRel == "." || localRel == ".." || strings.HasPrefix(localRel, ".."+string(filepath.Separator)) || filepath.IsAbs(localRel) { + return "", fmt.Errorf("invalid shared link relative path %q", rel) + } + return filepath.Join(root, localRel), nil +} + func downloadSharedLinkToFile(dbx sharedLinkClient, arg *sharing.GetSharedLinkMetadataArg, target string, errOut io.Writer) (string, error) { var dst string err := retryWithBackoff(func() error { @@ -219,7 +440,7 @@ var shareLinkDownloadCmd = &cobra.Command{ - If target is omitted, the local filename comes from shared-link metadata. - Use - as target to write file bytes to stdout. Stdout is byte-clean: all progress and errors go to stderr. - - Folder-link recursive download is not supported by this command. + - Use --recursive (-r) to download folder shared links. `, Example: ` dbxcli share-link download https://www.dropbox.com/s/example/file.txt dbxcli share-link download https://www.dropbox.com/s/example/file.txt ./local-file.txt @@ -228,6 +449,7 @@ var shareLinkDownloadCmd = &cobra.Command{ } func init() { - shareLinkDownloadCmd.Flags().String("password", "", "Password for password-protected shared links") + addSharedLinkPasswordFlags(shareLinkDownloadCmd) + shareLinkDownloadCmd.Flags().BoolP("recursive", "r", false, "Recursively download a folder shared link") shareLinkCmd.AddCommand(shareLinkDownloadCmd) } diff --git a/cmd/share_link_download_test.go b/cmd/share_link_download_test.go index 4989c6e..8d5de7b 100644 --- a/cmd/share_link_download_test.go +++ b/cmd/share_link_download_test.go @@ -24,6 +24,7 @@ import ( "testing" "time" + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/sharing" "github.com/spf13/cobra" ) @@ -137,6 +138,102 @@ func TestShareLinkDownloadUsesMetadataNameAndPassword(t *testing.T) { } } +func TestShareLinkDownloadReadsPasswordPrompt(t *testing.T) { + orig := readSharedLinkPassword + readSharedLinkPassword = func(prompt string, in io.Reader, errOut io.Writer) (string, error) { + if prompt != "Shared link password: " { + t.Fatalf("prompt = %q, want shared link password prompt", prompt) + } + return "prompt-secret", nil + } + t.Cleanup(func() { readSharedLinkPassword = orig }) + + content := "shared content" + var requested *sharing.GetSharedLinkMetadataArg + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) { + requested = arg + return downloadableSharedLinkFile("report.txt", "/docs/report.txt", "https://example.com/link", uint64(len(content))), + io.NopCloser(strings.NewReader(content)), nil + }, + }) + + target := filepath.Join(t.TempDir(), "report.txt") + cmd := newShareLinkDownloadTestCommand(nil, nil) + if err := cmd.Flags().Set("password-prompt", "true"); err != nil { + t.Fatalf("set password-prompt: %v", err) + } + + if err := shareLinkDownload(cmd, []string{"https://example.com/link", target}); err != nil { + t.Fatalf("shareLinkDownload error: %v", err) + } + if requested == nil { + t.Fatal("GetSharedLinkFile was not called") + } + if requested.LinkPassword != "prompt-secret" { + t.Fatalf("password = %q, want prompt-secret", requested.LinkPassword) + } +} + +func TestShareLinkDownloadReadsPasswordFile(t *testing.T) { + passwordFile := filepath.Join(t.TempDir(), "password.txt") + if err := os.WriteFile(passwordFile, []byte("file-secret\n"), 0600); err != nil { + t.Fatalf("write password file: %v", err) + } + + content := "shared content" + var requested *sharing.GetSharedLinkMetadataArg + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) { + requested = arg + return downloadableSharedLinkFile("report.txt", "/docs/report.txt", "https://example.com/link", uint64(len(content))), + io.NopCloser(strings.NewReader(content)), nil + }, + }) + + target := filepath.Join(t.TempDir(), "report.txt") + cmd := newShareLinkDownloadTestCommand(nil, nil) + if err := cmd.Flags().Set("password-file", passwordFile); err != nil { + t.Fatalf("set password-file: %v", err) + } + + if err := shareLinkDownload(cmd, []string{"https://example.com/link", target}); err != nil { + t.Fatalf("shareLinkDownload error: %v", err) + } + if requested == nil { + t.Fatal("GetSharedLinkFile was not called") + } + if requested.LinkPassword != "file-secret" { + t.Fatalf("password = %q, want file-secret", requested.LinkPassword) + } +} + +func TestShareLinkDownloadRejectsMultiplePasswordSources(t *testing.T) { + called := false + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) { + called = true + return nil, nil, nil + }, + }) + + cmd := newShareLinkDownloadTestCommand(nil, nil) + if err := cmd.Flags().Set("password", "secret"); err != nil { + t.Fatalf("set password: %v", err) + } + if err := cmd.Flags().Set("password-file", filepath.Join(t.TempDir(), "password.txt")); err != nil { + t.Fatalf("set password-file: %v", err) + } + + err := shareLinkDownload(cmd, []string{"https://example.com/link", filepath.Join(t.TempDir(), "target")}) + if err == nil || !strings.Contains(err.Error(), "use only one of `--password`, `--password-prompt`, or `--password-file`") { + t.Fatalf("error = %v, want password source error", err) + } + if called { + t.Fatal("GetSharedLinkFile should not be called") + } +} + func TestShareLinkDownloadUsesExplicitTarget(t *testing.T) { tmp := t.TempDir() content := "shared content" @@ -176,6 +273,297 @@ func TestShareLinkDownloadUsesTargetDirectory(t *testing.T) { assertFileContent(t, filepath.Join(targetDir, "report.txt"), content) } +func TestShareLinkDownloadFolderRequiresRecursive(t *testing.T) { + called := false + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + return sharedLinkFolder("/docs", "https://example.com/folder"), nil + }, + getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) { + called = true + return nil, nil, nil + }, + }) + + err := shareLinkDownload(newShareLinkDownloadTestCommand(nil, nil), []string{"https://example.com/folder", filepath.Join(t.TempDir(), "target")}) + if err == nil || !strings.Contains(err.Error(), "--recursive") { + t.Fatalf("error = %v, want recursive error", err) + } + if called { + t.Fatal("GetSharedLinkFile should not be called") + } +} + +func TestShareLinkDownloadFolderRejectsStdoutTarget(t *testing.T) { + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + return sharedLinkFolder("/docs", "https://example.com/folder"), nil + }, + }) + + cmd := newShareLinkDownloadTestCommand(nil, nil) + if err := cmd.Flags().Set("recursive", "true"); err != nil { + t.Fatalf("set recursive: %v", err) + } + + err := shareLinkDownload(cmd, []string{"https://example.com/folder", "-"}) + if err == nil || !strings.Contains(err.Error(), "stdout") { + t.Fatalf("error = %v, want stdout folder error", err) + } +} + +func TestShareLinkDownloadFolderRecursiveDownloadsNestedFiles(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "out") + var listed []string + var listedPassword string + var downloaded []string + + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + if arg.LinkPassword != "secret" { + t.Fatalf("metadata password = %q, want secret", arg.LinkPassword) + } + return sharedLinkFolder("/docs", "https://example.com/folder"), nil + }, + getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) { + downloaded = append(downloaded, arg.Path) + if arg.LinkPassword != "secret" { + t.Fatalf("download password = %q, want secret", arg.LinkPassword) + } + contents := strings.TrimPrefix(arg.Path, "/") + return downloadableSharedLinkFile(filepath.Base(arg.Path), arg.Path, "https://example.com/folder", uint64(len(contents))), + io.NopCloser(strings.NewReader(contents)), nil + }, + }) + stubFilesClient(t, &mockFilesClient{ + listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + listed = append(listed, arg.Path) + if arg.SharedLink == nil { + t.Fatal("SharedLink = nil, want shared-link listing") + } + if arg.SharedLink.Url != "https://example.com/folder" { + t.Fatalf("shared link URL = %q, want https://example.com/folder", arg.SharedLink.Url) + } + listedPassword = arg.SharedLink.Password + if arg.Recursive { + t.Fatal("Recursive = true, want manual recursion for shared links") + } + switch arg.Path { + case "": + return &files.ListFolderResult{ + Entries: []files.IsMetadata{ + &files.FileMetadata{Metadata: files.Metadata{PathDisplay: "/docs/root.txt"}, Size: 8}, + &files.FolderMetadata{Metadata: files.Metadata{PathDisplay: "/docs/sub"}}, + }, + }, nil + case "/sub": + return &files.ListFolderResult{ + Entries: []files.IsMetadata{ + &files.FileMetadata{Metadata: files.Metadata{PathDisplay: "/docs/sub/deep.txt"}, Size: 12}, + }, + }, nil + default: + t.Fatalf("unexpected list path %q", arg.Path) + } + return nil, nil + }, + }) + + cmd := newShareLinkDownloadTestCommand(nil, nil) + if err := cmd.Flags().Set("recursive", "true"); err != nil { + t.Fatalf("set recursive: %v", err) + } + if err := cmd.Flags().Set("password", "secret"); err != nil { + t.Fatalf("set password: %v", err) + } + + if err := shareLinkDownload(cmd, []string{"https://example.com/folder", target}); err != nil { + t.Fatalf("shareLinkDownload error: %v", err) + } + + if strings.Join(listed, ",") != ",/sub" { + t.Fatalf("listed paths = %q, want root then sub", strings.Join(listed, ",")) + } + if listedPassword != "secret" { + t.Fatalf("listed password = %q, want secret", listedPassword) + } + if strings.Join(downloaded, ",") != "/root.txt,/sub/deep.txt" { + t.Fatalf("downloaded paths = %q, want root and nested file", strings.Join(downloaded, ",")) + } + assertFileContent(t, filepath.Join(target, "root.txt"), "root.txt") + assertFileContent(t, filepath.Join(target, "sub", "deep.txt"), "sub/deep.txt") +} + +func TestShareLinkDownloadFolderUsesExistingTargetDirectory(t *testing.T) { + tmp := t.TempDir() + targetDir := filepath.Join(tmp, "downloads") + if err := os.Mkdir(targetDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + return sharedLinkFolder("/docs", "https://example.com/folder"), nil + }, + getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) { + return downloadableSharedLinkFile("root.txt", arg.Path, "https://example.com/folder", 4), + io.NopCloser(strings.NewReader("data")), nil + }, + }) + stubFilesClient(t, &mockFilesClient{ + listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + return &files.ListFolderResult{ + Entries: []files.IsMetadata{ + &files.FileMetadata{Metadata: files.Metadata{PathDisplay: "/docs/root.txt"}, Size: 4}, + }, + }, nil + }, + }) + + cmd := newShareLinkDownloadTestCommand(nil, nil) + if err := cmd.Flags().Set("recursive", "true"); err != nil { + t.Fatalf("set recursive: %v", err) + } + + if err := shareLinkDownload(cmd, []string{"https://example.com/folder", targetDir}); err != nil { + t.Fatalf("shareLinkDownload error: %v", err) + } + assertFileContent(t, filepath.Join(targetDir, "docs", "root.txt"), "data") +} + +func TestShareLinkDownloadFolderFollowsPagination(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "out") + var continued string + + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + return sharedLinkFolder("/docs", "https://example.com/folder"), nil + }, + getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) { + return downloadableSharedLinkFile(filepath.Base(arg.Path), arg.Path, "https://example.com/folder", 4), + io.NopCloser(strings.NewReader("data")), nil + }, + }) + stubFilesClient(t, &mockFilesClient{ + listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + return &files.ListFolderResult{ + Entries: []files.IsMetadata{ + &files.FileMetadata{Metadata: files.Metadata{PathDisplay: "/docs/page-one.txt"}, Size: 4}, + }, + HasMore: true, + Cursor: "cursor-2", + }, nil + }, + listFolderContinueFn: func(arg *files.ListFolderContinueArg) (*files.ListFolderResult, error) { + continued = arg.Cursor + return &files.ListFolderResult{ + Entries: []files.IsMetadata{ + &files.FileMetadata{Metadata: files.Metadata{PathDisplay: "/docs/page-two.txt"}, Size: 4}, + }, + }, nil + }, + }) + + cmd := newShareLinkDownloadTestCommand(nil, nil) + if err := cmd.Flags().Set("recursive", "true"); err != nil { + t.Fatalf("set recursive: %v", err) + } + + if err := shareLinkDownload(cmd, []string{"https://example.com/folder", target}); err != nil { + t.Fatalf("shareLinkDownload error: %v", err) + } + if continued != "cursor-2" { + t.Fatalf("continue cursor = %q, want cursor-2", continued) + } + assertFileContent(t, filepath.Join(target, "page-one.txt"), "data") + assertFileContent(t, filepath.Join(target, "page-two.txt"), "data") +} + +func TestShareLinkDownloadFolderDoesNotCreateTargetWhenRootListFails(t *testing.T) { + target := filepath.Join(t.TempDir(), "out") + stubSharedLinkClient(t, &mockSharedLinkClient{ + getSharedLinkMetadataFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, error) { + return sharedLinkFolder("/docs", "https://example.com/folder"), nil + }, + getSharedLinkFileFn: func(arg *sharing.GetSharedLinkMetadataArg) (sharing.IsSharedLinkMetadata, io.ReadCloser, error) { + t.Fatal("GetSharedLinkFile should not be called when root listing fails") + return nil, nil, nil + }, + }) + stubFilesClient(t, &mockFilesClient{ + listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + return nil, fmt.Errorf("root list failed") + }, + }) + + cmd := newShareLinkDownloadTestCommand(nil, nil) + if err := cmd.Flags().Set("recursive", "true"); err != nil { + t.Fatalf("set recursive: %v", err) + } + + err := shareLinkDownload(cmd, []string{"https://example.com/folder", target}) + if err == nil || !strings.Contains(err.Error(), "root list failed") { + t.Fatalf("error = %v, want root list failure", err) + } + if _, statErr := os.Stat(target); !os.IsNotExist(statErr) { + t.Fatalf("target stat error = %v, want target not created", statErr) + } +} + +func TestSharedLinkEntryRelativePathStripsRootBySegment(t *testing.T) { + tests := []struct { + name string + pathDisplay string + rootName string + want string + }{ + { + name: "root itself", + pathDisplay: "/docs", + rootName: "docs", + want: "", + }, + { + name: "child", + pathDisplay: "/docs/root.txt", + rootName: "docs", + want: "root.txt", + }, + { + name: "case-insensitive root segment", + pathDisplay: "/DOCS/root.txt", + rootName: "docs", + want: "root.txt", + }, + { + name: "partial prefix is not stripped", + pathDisplay: "/docs-other/root.txt", + rootName: "docs", + want: "docs-other/root.txt", + }, + { + name: "unicode root segment", + pathDisplay: "/\u0130/root.txt", + rootName: "\u0130", + want: "root.txt", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := sharedLinkEntryRelativePath(tt.pathDisplay, tt.rootName) + if err != nil { + t.Fatalf("sharedLinkEntryRelativePath error: %v", err) + } + if got != tt.want { + t.Fatalf("relative path = %q, want %q", got, tt.want) + } + }) + } +} + func TestShareLinkDownloadToStdoutIsByteClean(t *testing.T) { content := "shared stdout content" stubSharedLinkClient(t, &mockSharedLinkClient{ @@ -350,11 +738,24 @@ func TestShareLinkDownloadCommandIsRegistered(t *testing.T) { if cmd != shareLinkDownloadCmd { t.Fatalf("share-link download resolved to %q", cmd.CommandPath()) } + if shareLinkDownloadCmd.Flags().Lookup("password") == nil { + t.Fatal("share-link download should define --password") + } + if shareLinkDownloadCmd.Flags().Lookup("password-prompt") == nil { + t.Fatal("share-link download should define --password-prompt") + } + if shareLinkDownloadCmd.Flags().Lookup("password-file") == nil { + t.Fatal("share-link download should define --password-file") + } + if shareLinkDownloadCmd.Flags().Lookup("recursive") == nil { + t.Fatal("share-link download should define --recursive") + } } func newShareLinkDownloadTestCommand(stdout, stderr *bytes.Buffer) *cobra.Command { cmd := &cobra.Command{} - cmd.Flags().String("password", "", "") + addSharedLinkPasswordFlags(cmd) + cmd.Flags().BoolP("recursive", "r", false, "") cmd.Flags().Bool("verbose", false, "") if stdout != nil { cmd.SetOut(stdout) diff --git a/cmd/share_link_password.go b/cmd/share_link_password.go new file mode 100644 index 0000000..79496fa --- /dev/null +++ b/cmd/share_link_password.go @@ -0,0 +1,190 @@ +// 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 ( + "bufio" + "errors" + "fmt" + "io" + "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" +) + +type sharedLinkPasswordOptions struct { + password string + set bool +} + +var readSharedLinkPassword = defaultReadSharedLinkPassword + +func addSharedLinkPasswordFlags(cmd *cobra.Command) { + cmd.Flags().String("password", "", "Password for password-protected shared links") + cmd.Flags().Bool("password-prompt", false, "Prompt for the shared link password") + cmd.Flags().String("password-file", "", "Read the shared link password from a file") +} + +func sharedLinkPasswordFromFlags(cmd *cobra.Command) (sharedLinkPasswordOptions, error) { + var sourceCount int + passwordChanged := localFlagChanged(cmd, "password") + if passwordChanged { + sourceCount++ + } + + passwordPrompt, err := localBoolFlag(cmd, "password-prompt") + if err != nil { + return sharedLinkPasswordOptions{}, err + } + if passwordPrompt { + sourceCount++ + } + + passwordFile, err := localStringFlag(cmd, "password-file") + if err != nil { + return sharedLinkPasswordOptions{}, err + } + if passwordFile != "" { + sourceCount++ + } + + if sourceCount == 0 { + return sharedLinkPasswordOptions{}, nil + } + if sourceCount > 1 { + return sharedLinkPasswordOptions{}, errors.New("use only one of `--password`, `--password-prompt`, or `--password-file`") + } + + var password string + switch { + case passwordChanged: + password, err = cmd.Flags().GetString("password") + case passwordPrompt: + password, err = readSharedLinkPassword("Shared link password: ", cmd.InOrStdin(), cmd.ErrOrStderr()) + case passwordFile != "": + password, err = sharedLinkPasswordFromFile(passwordFile) + } + if err != nil { + return sharedLinkPasswordOptions{}, err + } + if password == "" { + return sharedLinkPasswordOptions{}, errors.New("shared link password cannot be empty") + } + + return sharedLinkPasswordOptions{ + password: password, + set: true, + }, nil +} + +func defaultReadSharedLinkPassword(prompt string, in io.Reader, errOut io.Writer) (string, error) { + if errOut == nil { + errOut = io.Discard + } + if _, err := fmt.Fprint(errOut, prompt); err != nil { + return "", err + } + + if f, ok := in.(*os.File); ok && term.IsTerminal(int(f.Fd())) { + password, err := term.ReadPassword(int(f.Fd())) + _, _ = fmt.Fprintln(errOut) + if err != nil { + return "", err + } + return string(password), nil + } + + line, err := bufio.NewReader(in).ReadString('\n') + if err != nil && err != io.EOF { + return "", err + } + return strings.TrimRight(line, "\r\n"), nil +} + +func sharedLinkPasswordFromFile(filePath string) (string, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return "", err + } + return strings.TrimRight(string(data), "\r\n"), nil +} + +func localFlagChanged(cmd *cobra.Command, name string) bool { + return cmd.Flags().Lookup(name) != nil && cmd.Flags().Changed(name) +} + +func localBoolFlag(cmd *cobra.Command, name string) (bool, error) { + if cmd.Flags().Lookup(name) == nil { + return false, nil + } + return cmd.Flags().GetBool(name) +} + +func localStringFlag(cmd *cobra.Command, name string) (string, error) { + if cmd.Flags().Lookup(name) == nil { + return "", nil + } + 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 new file mode 100644 index 0000000..c70dca0 --- /dev/null +++ b/cmd/share_link_password_test.go @@ -0,0 +1,75 @@ +// 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_update.go b/cmd/share_link_update.go index eed2d1b..cf71f49 100644 --- a/cmd/share_link_update.go +++ b/cmd/share_link_update.go @@ -28,6 +28,8 @@ type shareLinkUpdateOptions struct { removeExpiration bool allowDownload bool audience *sharing.LinkAudience + password sharedLinkPasswordOptions + removePassword bool } func shareLinkUpdate(cmd *cobra.Command, args []string) error { @@ -55,13 +57,24 @@ func shareLinkUpdate(cmd *cobra.Command, args []string) error { if opts.audience != nil { settings.Audience = opts.audience } + if opts.password.set { + settings.RequirePassword = true + settings.LinkPassword = opts.password.password + } arg := sharing.NewModifySharedLinkSettingsArgs(url, settings) arg.RemoveExpiration = opts.removeExpiration dbx := newSharedLinkClient(config) - if _, err := dbx.ModifySharedLinkSettings(arg); err != nil { - return err + 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) @@ -80,11 +93,22 @@ func parseShareLinkUpdateOptions(cmd *cobra.Command) (shareLinkUpdateOptions, er return shareLinkUpdateOptions{}, err } audienceChanged := cmd.Flags().Changed("audience") + password, err := sharedLinkPasswordFromFlags(cmd) + if err != nil { + return shareLinkUpdateOptions{}, err + } + removePassword, err := localBoolFlag(cmd, "remove-password") + if err != nil { + return shareLinkUpdateOptions{}, err + } if expiresChanged && removeExpiration { return shareLinkUpdateOptions{}, errors.New("`--expires` and `--remove-expiration` cannot be used together") } - if !expiresChanged && !removeExpiration && !allowDownload && !audienceChanged { + 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 { return shareLinkUpdateOptions{}, errors.New("at least one shared link setting flag is required") } @@ -115,9 +139,15 @@ func parseShareLinkUpdateOptions(cmd *cobra.Command) (shareLinkUpdateOptions, er removeExpiration: removeExpiration, allowDownload: allowDownload, audience: audience, + password: password, + removePassword: removePassword, }, nil } +func (opts shareLinkUpdateOptions) hasSDKSettings() bool { + return opts.expires != nil || opts.removeExpiration || opts.allowDownload || opts.audience != nil || opts.password.set +} + var shareLinkUpdateCmd = &cobra.Command{ Use: "update ", Short: "Update shared link settings", @@ -129,5 +159,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") + 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 10df2c0..fc74890 100644 --- a/cmd/share_link_update_test.go +++ b/cmd/share_link_update_test.go @@ -17,6 +17,7 @@ package cmd import ( "bytes" "fmt" + "os" "strings" "testing" "time" @@ -251,6 +252,117 @@ func TestShareLinkUpdateAllowsDownload(t *testing.T) { } } +func TestShareLinkUpdateSetsPassword(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.RequirePassword || arg.Settings.LinkPassword != "secret" { + t.Fatalf("settings = %#v, want password settings", arg.Settings) + } + 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) + + cmd := newShareLinkUpdateTestCommand(nil, nil) + 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) + } +} + +func TestShareLinkUpdateReadsPasswordFile(t *testing.T) { + passwordFile := t.TempDir() + "/password.txt" + if err := os.WriteFile(passwordFile, []byte("file-secret\n"), 0600); err != nil { + t.Fatalf("write password file: %v", err) + } + + mock := &mockSharedLinkClient{ + modifySharedLinkSettingsFn: func(arg *sharing.ModifySharedLinkSettingsArgs) (sharing.IsSharedLinkMetadata, error) { + if arg.Settings == nil || !arg.Settings.RequirePassword || arg.Settings.LinkPassword != "file-secret" { + t.Fatalf("settings = %#v, want password from file", arg.Settings) + } + return sharedLinkFile("/file.txt", "https://example.com/link"), nil + }, + } + stubSharedLinkClient(t, mock) + + cmd := newShareLinkUpdateTestCommand(nil, nil) + if err := cmd.Flags().Set("password-file", passwordFile); err != nil { + t.Fatalf("set password-file: %v", err) + } + + if err := shareLinkUpdate(cmd, []string{"https://example.com/link"}); err != nil { + t.Fatalf("shareLinkUpdate error: %v", err) + } +} + +func TestShareLinkUpdateRemovesPassword(t *testing.T) { + var removedURL 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 + 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 removedURL != "https://example.com/link" { + t.Fatalf("removed URL = %q, want https://example.com/link", removedURL) + } +} + +func TestShareLinkUpdateRejectsPasswordAndRemovePassword(t *testing.T) { + called := false + stubSharedLinkClient(t, &mockSharedLinkClient{ + modifySharedLinkSettingsFn: func(arg *sharing.ModifySharedLinkSettingsArgs) (sharing.IsSharedLinkMetadata, error) { + called = true + return nil, nil + }, + removeSharedLinkPasswordFn: func(url string) error { + called = true + return nil + }, + }) + + cmd := newShareLinkUpdateTestCommand(nil, nil) + if err := cmd.Flags().Set("password", "secret"); err != nil { + t.Fatalf("set password: %v", err) + } + if err := cmd.Flags().Set("remove-password", "true"); err != nil { + t.Fatalf("set remove-password: %v", err) + } + + err := shareLinkUpdate(cmd, []string{"https://example.com/link"}) + if err == nil || !strings.Contains(err.Error(), "password-setting flags and `--remove-password` cannot be used together") { + t.Fatalf("error = %v, want password mutual exclusion error", err) + } + if called { + t.Fatal("shared link API should not be called") + } +} + func TestShareLinkUpdateSetsAudience(t *testing.T) { mock := &mockSharedLinkClient{ modifySharedLinkSettingsFn: func(arg *sharing.ModifySharedLinkSettingsArgs) (sharing.IsSharedLinkMetadata, error) { @@ -339,6 +451,18 @@ func TestShareLinkUpdateCommandIsRegistered(t *testing.T) { if shareLinkUpdateCmd.Flags().Lookup("audience") == nil { t.Fatal("share-link update should define --audience") } + if shareLinkUpdateCmd.Flags().Lookup("password") == nil { + t.Fatal("share-link update should define --password") + } + if shareLinkUpdateCmd.Flags().Lookup("password-prompt") == nil { + t.Fatal("share-link update should define --password-prompt") + } + if shareLinkUpdateCmd.Flags().Lookup("password-file") == nil { + t.Fatal("share-link update should define --password-file") + } + if shareLinkUpdateCmd.Flags().Lookup("remove-password") == nil { + t.Fatal("share-link update should define --remove-password") + } } func newShareLinkUpdateTestCommand(stdout, stderr *bytes.Buffer) *cobra.Command { @@ -347,6 +471,8 @@ func newShareLinkUpdateTestCommand(stdout, stderr *bytes.Buffer) *cobra.Command cmd.Flags().String("expires", "", "") cmd.Flags().Bool("remove-expiration", false, "") cmd.Flags().Bool("allow-download", false, "") + addSharedLinkPasswordFlags(cmd) + cmd.Flags().Bool("remove-password", false, "") cmd.Flags().Bool("verbose", false, "") if stdout != nil { cmd.SetOut(stdout) diff --git a/go.mod b/go.mod index 1dc996c..e41c704 100644 --- a/go.mod +++ b/go.mod @@ -9,9 +9,11 @@ require ( github.com/mitchellh/ioprogress v0.0.0-20180201004757-6a23b12fa88e github.com/spf13/cobra v1.10.2 golang.org/x/oauth2 v0.36.0 + golang.org/x/term v0.44.0 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/sys v0.46.0 // indirect ) diff --git a/go.sum b/go.sum index 30c0ae4..c2eba8b 100644 --- a/go.sum +++ b/go.sum @@ -234,6 +234,10 @@ golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=