diff --git a/cmd/portal/config.go b/cmd/portal/config.go new file mode 100644 index 0000000..1eefeb6 --- /dev/null +++ b/cmd/portal/config.go @@ -0,0 +1,145 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/SpatiumPortae/portal/internal/config" + "github.com/alecthomas/chroma/quick" + homedir "github.com/mitchellh/go-homedir" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func init() { + configCmd.AddCommand(configPathCmd) + configCmd.AddCommand(configViewCmd) + configCmd.AddCommand(configEditCmd) + configCmd.AddCommand(configResetCmd) +} + +var configCmd = &cobra.Command{ + Use: "config", + Short: "View and configure options", + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + ValidArgs: []string{configPathCmd.Name(), configViewCmd.Name(), configEditCmd.Name(), configResetCmd.Name()}, + Run: func(cmd *cobra.Command, args []string) {}, +} + +var configPathCmd = &cobra.Command{ + Use: "path", + Short: "Output the path of the config file", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println(viper.ConfigFileUsed()) + }, +} + +var configViewCmd = &cobra.Command{ + Use: "view", + Short: "View the configured options", + RunE: func(cmd *cobra.Command, args []string) error { + configPath := viper.ConfigFileUsed() + config, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("config file (%s) could not be read: %w", configPath, err) + } + if err := quick.Highlight(os.Stdout, string(config), "yaml", "terminal256", "onedark"); err != nil { + // Failed to highlight output, output un-highlighted config file contents. + fmt.Println(string(config)) + } + return nil + }, +} + +var configEditCmd = &cobra.Command{ + Use: "edit", + Short: "Edit the configuration file", + RunE: func(cmd *cobra.Command, args []string) error { + configPath := viper.ConfigFileUsed() + // Strip arguments from editor variable -- allows exec.Command to lookup the editor executable correctly. + editor, _, _ := strings.Cut(os.Getenv("EDITOR"), " ") + if len(editor) == 0 { + //lint:ignore ST1005 error string is command output + return fmt.Errorf( + "Could not find default editor (is the $EDITOR variable set?)\nOptionally you can open the file (%s) manually", configPath, + ) + } + + editorCmd := exec.Command(editor, configPath) + editorCmd.Stdin = os.Stdin + editorCmd.Stdout = os.Stdout + editorCmd.Stderr = os.Stderr + if err := editorCmd.Run(); err != nil { + return fmt.Errorf("failed to open file (%s) in editor (%s): %w", configPath, editor, err) + } + return nil + }, +} + +var configResetCmd = &cobra.Command{ + Use: "reset", + Short: "Reset to the default configuration", + RunE: func(cmd *cobra.Command, args []string) error { + configPath := viper.ConfigFileUsed() + err := os.WriteFile(configPath, config.ToYaml(config.GetDefault()), 0) + if err != nil { + return fmt.Errorf("config file (%s) could not be read/written to: %w", configPath, err) + } + return nil + }, +} + +// -------------------------------------------------- Helper Functions ------------------------------------------------- + +// initConfig initializes the viper config. +// `config.yml` is created in $HOME/.config/portal if not already existing. +// NOTE: The precedence levels of viper are the following: flags -> config file -> defaults. +func initConfig() { + home, err := homedir.Dir() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + configPath := filepath.Join(home, config.CONFIGS_DIR_NAME, config.PORTAL_CONFIG_DIR_NAME) + viper.AddConfigPath(configPath) + viper.SetConfigName(config.CONFIG_FILE_NAME) + viper.SetConfigType(config.CONFIG_FILE_EXT) + + if err := viper.ReadInConfig(); err != nil { + // Create config file if not found. + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + err := os.MkdirAll(configPath, os.ModePerm) + if err != nil { + fmt.Println("Could not create config directory:", err) + os.Exit(1) + } + + configFile, err := os.Create(filepath.Join(configPath, fmt.Sprintf("%s.%s", config.CONFIG_FILE_NAME, config.CONFIG_FILE_EXT))) + if err != nil { + fmt.Println("Could not create config file:", err) + os.Exit(1) + } + defer configFile.Close() + + _, err = configFile.Write(config.ToYaml(config.GetDefault())) + if err != nil { + fmt.Println("Could not write defaults to config file:", err) + os.Exit(1) + } + } else { + fmt.Println("Could not read config file:", err) + os.Exit(1) + } + } +} + +// Sets default viper values. +func setDefaults() { + for k, v := range config.ToMap(config.GetDefault()) { + viper.SetDefault(k, v) + } +} diff --git a/cmd/portal/constants.go b/cmd/portal/constants.go deleted file mode 100644 index b190b0f..0000000 --- a/cmd/portal/constants.go +++ /dev/null @@ -1,11 +0,0 @@ -package main - -const CONFIG_FILE_NAME = ".portal.yml" - -const DEFAULT_RENDEZVOUS_ADDRESS = "167.71.65.96" -const DEFAULT_RENDEZVOUS_PORT = 80 - -const DEFAULT_CONFIG_YAML = `config: - default_rendezvous_address: 167.71.65.96 - default_rendezvous_port: 80 -` diff --git a/cmd/portal/main.go b/cmd/portal/main.go index 7aa0043..fc5c411 100644 --- a/cmd/portal/main.go +++ b/cmd/portal/main.go @@ -1,17 +1,12 @@ package main import ( - "errors" "fmt" "io" "log" - "net" "os" - "path/filepath" - "unicode/utf8" tea "github.com/charmbracelet/bubbletea" - homedir "github.com/mitchellh/go-homedir" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -20,6 +15,22 @@ import ( // injected at link time using -ldflags. var version string +// Initialization of cobra and viper. +func init() { + initConfig() + setDefaults() + + rootCmd.PersistentFlags().BoolP("verbose", "v", false, "Log debug information to a file on the format `.portal-[command].log` in the current directory") + // Add cobra subcommands. + rootCmd.AddCommand(sendCmd) + rootCmd.AddCommand(receiveCmd) + rootCmd.AddCommand(serveCmd) + rootCmd.AddCommand(versionCmd) + rootCmd.AddCommand(configCmd) +} + +// ------------------------------------------------------ Command ------------------------------------------------------ + // rootCmd is the top level `portal` command on which the other subcommands are attached to. var rootCmd = &cobra.Command{ Use: "portal", @@ -31,7 +42,8 @@ var rootCmd = &cobra.Command{ } var versionCmd = &cobra.Command{ - Use: "version", + Use: "version", + Short: "Display the installed version of portal", Run: func(cmd *cobra.Command, args []string) { fmt.Println(version) os.Exit(0) @@ -45,140 +57,16 @@ func main() { } } -// Initialization of cobra and viper. -func init() { - cobra.OnInitialize(initViperConfig) - - rootCmd.PersistentFlags().BoolP("verbose", "v", false, "Specifes if portal logs debug information to a file on the format `.portal-[command].log` in the current directory") - // Setup viper config. - // Add cobra subcommands. - rootCmd.AddCommand(sendCmd) - rootCmd.AddCommand(receiveCmd) - rootCmd.AddCommand(serveCmd) - rootCmd.AddCommand(versionCmd) -} - -// HELPER FUNCTIONS - -// initViperConfig initializes the viper config. -// It creates a `.portal.yml` file at the home directory if it has not been created earlier -// NOTE: The precedence levels of viper are the following: flags -> config file -> defaults -// See https://github.com/spf13/viper#why-viper -func initViperConfig() { - // Set default values - viper.SetDefault("verbose", false) - viper.SetDefault("rendezvousPort", DEFAULT_RENDEZVOUS_PORT) - viper.SetDefault("rendezvousAddress", DEFAULT_RENDEZVOUS_ADDRESS) - - // Find home directory. - home, err := homedir.Dir() - if err != nil { - fmt.Println(err) - os.Exit(1) - } - - // Search for config in home directory. - viper.AddConfigPath(home) - viper.SetConfigName(CONFIG_FILE_NAME) - viper.SetConfigType("yaml") - - if err := viper.ReadInConfig(); err != nil { - // Create config file if not found - // NOTE: perhaps should be an empty file initially, as we would not want default IP to be written to a file on the user host - if _, ok := err.(viper.ConfigFileNotFoundError); ok { - configPath := filepath.Join(home, CONFIG_FILE_NAME) - configFile, err := os.Create(configPath) - if err != nil { - fmt.Println("Could not create config file:", err) - os.Exit(1) - } - defer configFile.Close() - _, err = configFile.Write([]byte(DEFAULT_CONFIG_YAML)) - if err != nil { - fmt.Println("Could not write defaults to config file:", err) - os.Exit(1) - } - } else { - fmt.Println("Could not read config file:", err) - os.Exit(1) - } - } -} - -// validateRendezvousAddressInViper validates that the `rendezvousAddress` value in viper is a valid hostname or IP -func validateRendezvousAddressInViper() error { - rendezvouzAdress := net.ParseIP(viper.GetString("rendezvousAddress")) - err := validateHostname(viper.GetString("rendezvousAddress")) - // neither a valid IP nor a valid hostname was provided - if (rendezvouzAdress == nil) && err != nil { - return errors.New("invalid IP or hostname provided") - } - return nil -} +// -------------------------------------------------- Helper Functions ------------------------------------------------- func setupLoggingFromViper(cmd string) (*os.File, error) { if viper.GetBool("verbose") { f, err := tea.LogToFile(fmt.Sprintf(".portal-%s.log", cmd), fmt.Sprintf("portal-%s: \n", cmd)) if err != nil { - return nil, fmt.Errorf("could not log to the provided file") + return nil, fmt.Errorf("could not log to the provided file: %w", err) } return f, nil } log.SetOutput(io.Discard) return nil, nil } - -// validateHostname returns an error if the domain name is not valid -// See https://tools.ietf.org/html/rfc1034#section-3.5 and -// https://tools.ietf.org/html/rfc1123#section-2. -// source: https://gist.github.com/chmike/d4126a3247a6d9a70922fc0e8b4f4013 -func validateHostname(name string) error { - switch { - case len(name) == 0: - return nil - case len(name) > 255: - return fmt.Errorf("name length is %d, can't exceed 255", len(name)) - } - var l int - for i := 0; i < len(name); i++ { - b := name[i] - if b == '.' { - // check domain labels validity - switch { - case i == l: - return fmt.Errorf("invalid character '%c' at offset %d: label can't begin with a period", b, i) - case i-l > 63: - return fmt.Errorf("byte length of label '%s' is %d, can't exceed 63", name[l:i], i-l) - case name[l] == '-': - return fmt.Errorf("label '%s' at offset %d begins with a hyphen", name[l:i], l) - case name[i-1] == '-': - return fmt.Errorf("label '%s' at offset %d ends with a hyphen", name[l:i], l) - } - l = i + 1 - continue - } - // test label character validity, note: tests are ordered by decreasing validity frequency - if !(b >= 'a' && b <= 'z' || b >= '0' && b <= '9' || b == '-' || b >= 'A' && b <= 'Z') { - // show the printable unicode character starting at byte offset i - c, _ := utf8.DecodeRuneInString(name[i:]) - if c == utf8.RuneError { - return fmt.Errorf("invalid rune at offset %d", i) - } - return fmt.Errorf("invalid character '%c' at offset %d", c, i) - } - } - // check top level domain validity - switch { - case l == len(name): - return fmt.Errorf("missing top level domain, domain can't end with a period") - case len(name)-l > 63: - return fmt.Errorf("byte length of top level domain '%s' is %d, can't exceed 63", name[l:], len(name)-l) - case name[l] == '-': - return fmt.Errorf("top level domain '%s' at offset %d begins with a hyphen", name[l:], l) - case name[len(name)-1] == '-': - return fmt.Errorf("top level domain '%s' at offset %d ends with a hyphen", name[l:], l) - case name[l] >= '0' && name[l] <= '9': - return fmt.Errorf("top level domain '%s' at offset %d begins with a digit", name[l:], l) - } - return nil -} diff --git a/cmd/portal/receive.go b/cmd/portal/receive.go index b39d203..b511841 100644 --- a/cmd/portal/receive.go +++ b/cmd/portal/receive.go @@ -16,6 +16,20 @@ import ( "golang.org/x/exp/slices" ) +// Setup flags. +func init() { + // Add subcommand flags (dummy default values as default values are handled through viper) + desc := `Address of relay server. Accepted formats: + - 127.0.0.1:8080 + - [::1]:8080 + - somedomain.com + ` + receiveCmd.Flags().StringP("relay", "r", "", desc) + receiveCmd.Flags().BoolP("yes", "y", false, "Overwrite existing files without [Y/n] prompts") +} + +// ------------------------------------------------------ Command ------------------------------------------------------ + // receiveCmd is the cobra command for `portal receive` var receiveCmd = &cobra.Command{ Use: "receive", @@ -23,18 +37,29 @@ var receiveCmd = &cobra.Command{ Long: "The receive command receives files from the sender with the matching password.", Args: cobra.ExactArgs(1), ValidArgsFunction: passwordCompletion, - PreRun: func(cmd *cobra.Command, args []string) { - // Bind flags to viper - //nolint - viper.BindPFlag("rendezvousPort", cmd.Flags().Lookup("rendezvous-port")) - //nolint - viper.BindPFlag("rendezvousAddress", cmd.Flags().Lookup("rendezvous-address")) + PreRunE: func(cmd *cobra.Command, args []string) error { + // Bind flags to viper. + if err := viper.BindPFlag("relay", cmd.Flags().Lookup("relay")); err != nil { + return fmt.Errorf("binding relay flag: %w", err) + } + + // Reverse the --yes/-y flag value as it has an inverse relationship + // with the configuration value 'prompt_overwrite_files'. + overwriteFlag := cmd.Flags().Lookup("yes") + if overwriteFlag.Changed { + shouldOverwrite, _ := strconv.ParseBool(overwriteFlag.Value.String()) + _ = overwriteFlag.Value.Set(strconv.FormatBool(!shouldOverwrite)) + } + + if err := viper.BindPFlag("prompt_overwrite_files", overwriteFlag); err != nil { + return fmt.Errorf("binding yes flag: %w", err) + } + return nil }, RunE: func(cmd *cobra.Command, args []string) error { file.RemoveTemporaryFiles(file.RECEIVE_TEMP_FILE_NAME_PREFIX) - err := validateRendezvousAddressInViper() - if err != nil { - return err + if err := validateRelayInViper(); err != nil { + return fmt.Errorf("%w (%s) is not a valid address", err, viper.GetString("relay")) } logFile, err := setupLoggingFromViper("receive") if err != nil { @@ -50,24 +75,16 @@ var receiveCmd = &cobra.Command{ }, } -// Setup flags. -func init() { - // Add subcommand flags (dummy default values as default values are handled through viper). - // TODO: recactor this into a single flag for providing a TCPAddr. - receiveCmd.Flags().IntP("rendezvous-port", "p", 0, "port on which the rendezvous server is running") - receiveCmd.Flags().StringP("rendezvous-address", "a", "", "host address for the rendezvous server") -} +// ------------------------------------------------------ Handler ------------------------------------------------------ // handleReceiveCommand is the receive application. func handleReceiveCommand(password string) { - addr := viper.GetString("rendezvousAddress") - port := viper.GetInt("rendezvousPort") var opts []receiver.Option ver, err := semver.Parse(version) if err == nil { opts = append(opts, receiver.WithVersion(ver)) } - receiver := receiver.New(fmt.Sprintf("%s:%d", addr, port), password, opts...) + receiver := receiver.New(viper.GetString("relay"), password, opts...) if _, err := receiver.Run(); err != nil { fmt.Println("Error initializing UI", err) diff --git a/cmd/portal/send.go b/cmd/portal/send.go index 277f561..92c9e13 100644 --- a/cmd/portal/send.go +++ b/cmd/portal/send.go @@ -12,26 +12,41 @@ import ( "github.com/spf13/viper" ) +// Set flags. +func init() { + // Add subcommand flags (dummy default values as default values are handled through viper) + desc := `Address of relay server. Accepted formats: + - 127.0.0.1:8080 + - [::1]:8080 + - somedomain.com + ` + sendCmd.Flags().StringP("relay", "r", "", desc) +} + +// ------------------------------------------------------ Command ------------------------------------------------------ + // sendCmd cobra command for `portal send`. var sendCmd = &cobra.Command{ Use: "send", Short: "Send one or more files", Long: "The send command adds one or more files to be sent. Files are archived and compressed before sending.", Args: cobra.MinimumNArgs(1), - PreRun: func(cmd *cobra.Command, args []string) { + PreRunE: func(cmd *cobra.Command, args []string) error { // Bind flags to viper - //nolint:errcheck - viper.BindPFlag("rendezvousPort", cmd.Flags().Lookup("rendezvous-port")) - //nolint:errcheck - viper.BindPFlag("rendezvousAddress", cmd.Flags().Lookup("rendezvous-address")) + if err := viper.BindPFlag("relay", cmd.Flags().Lookup("relay")); err != nil { + return fmt.Errorf("binding relay flag: %w", err) + } + return nil + }, RunE: func(cmd *cobra.Command, args []string) error { if err := sender.Init(); err != nil { return err } file.RemoveTemporaryFiles(file.SEND_TEMP_FILE_NAME_PREFIX) - if err := validateRendezvousAddressInViper(); err != nil { - return err + + if err := validateRelayInViper(); err != nil { + return fmt.Errorf("%w (%s) is not a valid address", err, viper.GetString("relay")) } logFile, err := setupLoggingFromViper("send") @@ -45,25 +60,18 @@ var sendCmd = &cobra.Command{ }, } -// Set flags. -func init() { - // Add subcommand flags (dummy default values as default values are handled through viper) - //TODO: refactor into a single flag providing a string - sendCmd.Flags().IntP("rendezvous-port", "p", 0, "port on which the rendezvous server is running") - sendCmd.Flags().StringP("rendezvous-address", "a", "", "host address for the rendezvous server") -} +// ------------------------------------------------------ Handler ------------------------------------------------------ // handleSendCommand is the sender application. func handleSendCommand(fileNames []string) { - addr := viper.GetString("rendezvousAddress") - port := viper.GetInt("rendezvousPort") var opts []senderui.Option ver, err := semver.Parse(version) // Conditionally add option to sender ui if err == nil { opts = append(opts, senderui.WithVersion(ver)) } - sender := senderui.New(fileNames, fmt.Sprintf("%s:%d", addr, port), opts...) + relayAddr := viper.GetString("relay") + sender := senderui.New(fileNames, relayAddr, opts...) if _, err := sender.Run(); err != nil { fmt.Println("Error initializing UI", err) os.Exit(1) diff --git a/cmd/portal/serve.go b/cmd/portal/serve.go index bd5a132..196c63d 100644 --- a/cmd/portal/serve.go +++ b/cmd/portal/serve.go @@ -8,12 +8,18 @@ import ( "github.com/spf13/cobra" ) +// NOTE: The `port` flag is required and not managed through viper. +func init() { + serveCmd.Flags().IntP("port", "p", 0, "port to run the portal relay server on") + _ = serveCmd.MarkFlagRequired("port") +} + // serveCmd is the cobra command for `portal serve` var serveCmd = &cobra.Command{ Use: "serve", - Short: "Serve the rendezvous-server", - Long: "The serve command serves the rendezvous-server locally.", - Args: cobra.NoArgs, + Short: "Serve the relay server", + Long: "The serve command serves the relay server locally.", + Args: cobra.MatchAll(cobra.ExactArgs(0), cobra.NoArgs), RunE: func(cmd *cobra.Command, args []string) error { port, _ := cmd.Flags().GetInt("port") ver, err := semver.Parse(version) @@ -25,10 +31,3 @@ var serveCmd = &cobra.Command{ return nil }, } - -// Add `port` flag. -// NOTE: The `port` flag is required and not managed through viper. -func init() { - serveCmd.Flags().IntP("port", "p", 0, "port to run the portal rendezvous server on") - _ = serveCmd.MarkFlagRequired("port") -} diff --git a/cmd/portal/validate.go b/cmd/portal/validate.go new file mode 100644 index 0000000..f3543e4 --- /dev/null +++ b/cmd/portal/validate.go @@ -0,0 +1,71 @@ +package main + +import ( + "errors" + "net" + "regexp" + "strconv" + "strings" + + "github.com/spf13/viper" + "golang.org/x/net/idna" +) + +var ErrInvalidRelay = errors.New("invalid relay address provided") + +var ipv6Rex = regexp.MustCompile(`\[(.*?)\]`) + +func stripPort(addr string) string { + split := strings.Split(addr, ":") + if len(split) == 2 { + return split[0] + } + + matches := ipv6Rex.FindStringSubmatch(addr) + if len(matches) >= 2 { + return matches[1] + } + return addr +} + +// validateRelayInViper validates that the `relay` value in viper is a valid hostname or IP +func validateRelayInViper() error { + relayAddr := viper.GetString("relay") + + onlyHost := stripPort(relayAddr) + + // Port is present, validate it. + if relayAddr != onlyHost { + _, port, err := net.SplitHostPort(relayAddr) + if err != nil { + return ErrInvalidRelay + } + portNumber, err := strconv.Atoi(port) + if err != nil { + return ErrInvalidRelay + } + if portNumber < 1 || portNumber > 65535 { + return ErrInvalidRelay + } + } + + // Only port is present, and was valid -- accept an address like ":5432". + if len(relayAddr) > 0 && len(onlyHost) == 0 { + return nil + } + + // On the form localhost or localhost:1234, valid. + if onlyHost == "localhost" { + return nil + } + + if ip := net.ParseIP(onlyHost); ip != nil { + return nil + } + + if _, err := idna.Lookup.ToASCII(relayAddr); err == nil { + return nil + } + + return ErrInvalidRelay +} diff --git a/go.mod b/go.mod index 894b48b..7cea214 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,14 @@ module github.com/SpatiumPortae/portal go 1.18 require ( + github.com/alecthomas/chroma v0.10.0 github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v0.15.0 github.com/charmbracelet/bubbletea v0.23.2 github.com/charmbracelet/lipgloss v0.6.0 github.com/docker/go-connections v0.4.0 + github.com/erikgeiser/promptkit v0.8.0 + github.com/fatih/structs v1.1.0 github.com/klauspost/pgzip v1.2.5 github.com/mattn/go-runewidth v0.0.14 github.com/mitchellh/go-homedir v1.1.0 @@ -17,6 +20,7 @@ require ( github.com/stretchr/testify v1.8.1 github.com/testcontainers/testcontainers-go v0.13.0 go.uber.org/zap v1.24.0 + golang.org/x/net v0.7.0 nhooyr.io/websocket v1.8.7 ) @@ -45,6 +49,7 @@ require ( github.com/containerd/cgroups v1.0.3 // indirect github.com/containerd/containerd v1.5.18 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dlclark/regexp2 v1.4.0 // indirect github.com/docker/distribution v2.8.0+incompatible // indirect github.com/docker/docker v20.10.11+incompatible // indirect github.com/docker/go-units v0.4.0 // indirect @@ -70,7 +75,6 @@ require ( go.opencensus.io v0.24.0 // indirect go.uber.org/atomic v1.10.0 // indirect go.uber.org/multierr v1.9.0 // indirect - golang.org/x/net v0.7.0 // indirect golang.org/x/sync v0.1.0 // indirect google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef // indirect google.golang.org/grpc v1.52.0 // indirect diff --git a/go.sum b/go.sum index d6825b6..909b151 100644 --- a/go.sum +++ b/go.sum @@ -80,6 +80,8 @@ github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAE github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= +github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= +github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -265,6 +267,8 @@ github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8l github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= +github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E= github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE= github.com/docker/distribution v0.0.0-20190905152932-14b96e55d84c/go.mod h1:0+TTO4EOBfRPhZXAeF1Vu+W3hHZ8eLp8PgKVZlcvtFY= @@ -296,9 +300,13 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/erikgeiser/promptkit v0.8.0 h1:bvOzPs6RLyfRZDSgVWOghQEiBSRHQ3zmDdxcV8zOc+E= +github.com/erikgeiser/promptkit v0.8.0/go.mod h1:QxyFbCrrj20PyvV5b+ckWPozbgX11s04GeRlmTCIMTo= github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3be111d --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,52 @@ +package config + +import ( + "fmt" + "strings" + + "github.com/fatih/structs" + "github.com/spf13/viper" +) + +const CONFIGS_DIR_NAME = ".config" +const PORTAL_CONFIG_DIR_NAME = "portal" +const CONFIG_FILE_NAME = "config" +const CONFIG_FILE_EXT = "yml" + +type Config struct { + Relay string `mapstructure:"relay"` + Verbose bool `mapstructure:"verbose"` + PromptOverwriteFiles bool `mapstructure:"prompt_overwrite_files"` +} + +func GetDefault() Config { + return Config{ + Relay: "167.71.65.96:80", + Verbose: false, + PromptOverwriteFiles: true, + } +} + +func ToMap(config Config) map[string]any { + m := map[string]any{} + for _, field := range structs.Fields(config) { + key := field.Tag("mapstructure") + value := field.Value() + m[key] = value + } + return m +} + +func ToYaml(config Config) []byte { + var builder strings.Builder + for k, v := range ToMap(config) { + builder.WriteString(fmt.Sprintf("%s: %v", k, v)) + builder.WriteRune('\n') + } + return []byte(builder.String()) +} + +func IsDefault(key string) bool { + defaults := ToMap(GetDefault()) + return viper.Get(key) == defaults[key] +} diff --git a/internal/file/file.go b/internal/file/file.go index 9ced7c7..d800e12 100644 --- a/internal/file/file.go +++ b/internal/file/file.go @@ -15,6 +15,8 @@ import ( const SEND_TEMP_FILE_NAME_PREFIX = "portal-send-temp" const RECEIVE_TEMP_FILE_NAME_PREFIX = "portal-receive-temp" +type OverwriteDecider func(fileName string) (bool, error) + func ReadFiles(fileNames []string) ([]*os.File, error) { var files []*os.File for _, fileName := range fileNames { @@ -27,9 +29,9 @@ func ReadFiles(fileNames []string) ([]*os.File, error) { return files, nil } -// ArchiveAndCompressFiles tars and gzip-compresses files into a temporary file, returning it +// PackFiles tars and gzip-compresses files into a temporary file, returning it // along with the resulting size -func ArchiveAndCompressFiles(files []*os.File) (*os.File, int64, error) { +func PackFiles(files []*os.File) (*os.File, int64, error) { // chained writers -> writing to tw writes to gw -> writes to temporary file tempFile, err := os.CreateTemp(os.TempDir(), SEND_TEMP_FILE_NAME_PREFIX) if err != nil { @@ -60,9 +62,9 @@ func ArchiveAndCompressFiles(files []*os.File) (*os.File, int64, error) { return tempFile, fileInfo.Size(), nil } -// DecompressAndUnarchiveBytes gzip-decompresses and un-tars files into the current working directory +// UnpackFiles gzip-decompresses and un-tars files into the current working directory // and returns the names and decompressed size of the created files -func DecompressAndUnarchiveBytes(reader io.Reader) ([]string, int64, error) { +func UnpackFiles(reader io.Reader, decideOverwrite OverwriteDecider) ([]string, int64, error) { // chained readers -> gr reads from reader -> tr reads from gr gr, err := pgzip.NewReader(reader) if err != nil { @@ -94,24 +96,39 @@ func DecompressAndUnarchiveBytes(reader io.Reader) ([]string, int64, error) { fileTarget := filepath.Join(cwd, header.Name) switch header.Typeflag { + case tar.TypeDir: if _, err := os.Stat(fileTarget); err != nil { if err := os.MkdirAll(fileTarget, 0755); err != nil { return nil, 0, err } } + case tar.TypeReg: + if fileExists(fileTarget) { + shouldOverwrite, err := decideOverwrite(fileTarget) + if err != nil { + return nil, 0, err + } + if !shouldOverwrite { + continue + } + } + f, err := os.OpenFile(fileTarget, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode)) if err != nil { return nil, 0, err } + if _, err := io.Copy(f, tr); err != nil { return nil, 0, err } + fileInfo, err := f.Stat() if err != nil { return nil, 0, err } + decompressedSize += fileInfo.Size() createdFiles = append(createdFiles, header.Name) f.Close() @@ -195,6 +212,11 @@ func addToTarArchive(tw *tar.Writer, file *os.File) error { }) } +func fileExists(filename string) bool { + _, err := os.Stat(filename) + return !os.IsNotExist(err) +} + // optimistically remove files created by portal with the specified prefix func RemoveTemporaryFiles(prefix string) { tempFiles, err := os.ReadDir(os.TempDir()) diff --git a/ui/constants.go b/ui/constants.go index 3ed634e..97afd17 100644 --- a/ui/constants.go +++ b/ui/constants.go @@ -26,10 +26,13 @@ const ( ) type KeyMap struct { - Quit key.Binding - CopyPassword key.Binding - FileListUp key.Binding - FileListDown key.Binding + Quit key.Binding + CopyPassword key.Binding + FileListUp key.Binding + FileListDown key.Binding + OverwritePromptYes key.Binding + OverwritePromptNo key.Binding + OverwritePromptConfirm key.Binding } func (k KeyMap) ShortHelp() []key.Binding { @@ -38,12 +41,23 @@ func (k KeyMap) ShortHelp() []key.Binding { k.CopyPassword, k.FileListUp, k.FileListDown, + k.OverwritePromptYes, + k.OverwritePromptNo, + k.OverwritePromptConfirm, } } func (k KeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ - {k.Quit, k.CopyPassword, k.FileListUp, k.FileListDown}, + { + k.Quit, + k.CopyPassword, + k.FileListUp, + k.FileListDown, + k.OverwritePromptYes, + k.OverwritePromptNo, + k.OverwritePromptConfirm, + }, } } @@ -73,6 +87,21 @@ var Keys = KeyMap{ key.WithHelp("(↓/j)", "file summary down"), key.WithDisabled(), ), + OverwritePromptYes: key.NewBinding( + key.WithKeys("y", "Y"), + key.WithHelp("(Y/y)", "accept overwrite"), + key.WithDisabled(), + ), + OverwritePromptNo: key.NewBinding( + key.WithKeys("n", "N"), + key.WithHelp("(N/n)", "deny overwrite"), + key.WithDisabled(), + ), + OverwritePromptConfirm: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("(⏎ )", "confirm choice"), + key.WithDisabled(), + ), } var PadText = strings.Repeat(" ", MARGIN) diff --git a/ui/filetable/filetable.go b/ui/filetable/filetable.go index d23f701..82f3d3a 100644 --- a/ui/filetable/filetable.go +++ b/ui/filetable/filetable.go @@ -162,5 +162,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m Model) View() string { + if len(m.rows) == 0 { + return "" + } return fileTableStyle.Render(m.table.View()) + "\n\n" } diff --git a/ui/receiver/receiver.go b/ui/receiver/receiver.go index 064c386..7cd5437 100644 --- a/ui/receiver/receiver.go +++ b/ui/receiver/receiver.go @@ -21,6 +21,9 @@ import ( "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/erikgeiser/promptkit" + "github.com/erikgeiser/promptkit/confirmation" + "github.com/spf13/viper" ) // ------------------------------------------------------ Ui State ----------------------------------------------------- @@ -31,6 +34,7 @@ const ( showEstablishing uiState = iota showReceivingProgress showDecompressing + showOverwritePrompt showFinished ) @@ -47,8 +51,16 @@ type receiveDoneMsg struct { temp *os.File } +type overwritePromptRequestMsg struct { + fileName string +} + +type overwritePromptResponseMsg struct { + shouldOverwrite bool +} + type decompressionDoneMsg struct { - filenames []string + fileNames []string decompressedPayloadSize int64 } @@ -67,8 +79,10 @@ type model struct { transferType transfer.Type password string - ctx context.Context - msgs chan interface{} + ctx context.Context + msgs chan interface{} + overwritePromptRequests chan overwritePromptRequestMsg + overwritePromptResponses chan overwritePromptResponseMsg rendezvousAddr string @@ -81,6 +95,7 @@ type model struct { spinner spinner.Model transferProgress transferprogress.Model fileTable filetable.Model + overwritePrompt confirmation.Model help help.Model keys ui.KeyMap } @@ -88,14 +103,17 @@ type model struct { // New creates a new receiver program. func New(addr string, password string, opts ...Option) *tea.Program { m := model{ - transferProgress: transferprogress.New(), - msgs: make(chan interface{}, 10), - fileTable: filetable.New(), - password: password, - rendezvousAddr: addr, - help: help.New(), - keys: ui.Keys, - ctx: context.Background(), + transferProgress: transferprogress.New(), + msgs: make(chan interface{}, 10), + overwritePromptRequests: make(chan overwritePromptRequestMsg), + overwritePromptResponses: make(chan overwritePromptResponseMsg), + password: password, + rendezvousAddr: addr, + fileTable: filetable.New(), + overwritePrompt: *confirmation.NewModel(confirmation.New("", confirmation.Undecided)), + help: help.New(), + keys: ui.Keys, + ctx: context.Background(), } for _, opt := range opts { opt(&m) @@ -173,6 +191,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case receiveDoneMsg: m.state = showDecompressing m.resetSpinner() + message := fmt.Sprintf("Transfer completed in %s with average transfer speed %s/s", time.Since(m.transferProgress.TransferStartTime).Round(time.Millisecond).String(), ui.ByteCountSI(m.transferProgress.TransferSpeedEstimateBps), @@ -180,11 +199,26 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.fileTable.SetMaxHeight(math.MaxInt) m.fileTable = m.fileTable.Finalize().(filetable.Model) - return m, ui.TaskCmd(message, tea.Batch(m.spinner.Tick, decompressCmd(msg.temp))) + + cmds := []tea.Cmd{m.spinner.Tick, + m.listenOverwritePromptRequestsCmd(), + m.decompressCmd(msg.temp), + } + + return m, ui.TaskCmd(message, tea.Batch(cmds...)) + + case overwritePromptRequestMsg: + m.state = showOverwritePrompt + m.resetSpinner() + m.keys.OverwritePromptYes.SetEnabled(true) + m.keys.OverwritePromptNo.SetEnabled(true) + m.keys.OverwritePromptConfirm.SetEnabled(true) + + return m, tea.Batch(m.spinner.Tick, m.newOverwritePrompt(msg.fileName)) case decompressionDoneMsg: m.state = showFinished - m.receivedFiles = msg.filenames + m.receivedFiles = msg.fileNames m.decompressedPayloadSize = msg.decompressedPayloadSize m.fileTable.SetFiles(m.receivedFiles) @@ -194,6 +228,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, ui.ErrorCmd(errors.New(msg.Error())) case tea.KeyMsg: + var cmds []tea.Cmd switch { case key.Matches(msg, m.keys.Quit): return m, tea.Quit @@ -201,21 +236,46 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { fileTableModel, fileTableCmd := m.fileTable.Update(msg) m.fileTable = fileTableModel.(filetable.Model) + cmds = append(cmds, fileTableCmd) + + _, promptCmd := m.overwritePrompt.Update(msg) + if m.state == showOverwritePrompt { + switch msg.String() { + case "left", "right": + cmds = append(cmds, promptCmd) + } + switch { + case key.Matches(msg, m.keys.OverwritePromptYes, m.keys.OverwritePromptNo, m.keys.OverwritePromptConfirm): + m.state = showDecompressing + m.keys.OverwritePromptYes.SetEnabled(false) + m.keys.OverwritePromptNo.SetEnabled(false) + m.keys.OverwritePromptConfirm.SetEnabled(false) + shouldOverwrite, _ := m.overwritePrompt.Value() + m.overwritePromptResponses <- overwritePromptResponseMsg{shouldOverwrite} + cmds = append(cmds, m.listenOverwritePromptRequestsCmd()) + } + } - return m, fileTableCmd + return m, tea.Batch(cmds...) case tea.WindowSizeMsg: m.width = msg.Width transferProgressModel, transferProgressCmd := m.transferProgress.Update(msg) m.transferProgress = transferProgressModel.(transferprogress.Model) + fileTableModel, fileTableCmd := m.fileTable.Update(msg) m.fileTable = fileTableModel.(filetable.Model) - return m, tea.Batch(transferProgressCmd, fileTableCmd) + + m.overwritePrompt.MaxWidth = msg.Width - 2*ui.MARGIN - 4 + _, promptCmd := m.overwritePrompt.Update(msg) + + return m, tea.Batch(transferProgressCmd, fileTableCmd, promptCmd) default: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd + var spinnerCmd tea.Cmd + m.spinner, spinnerCmd = m.spinner.Update(msg) + _, promptCmd := m.overwritePrompt.Update(msg) + return m, tea.Batch(spinnerCmd, promptCmd) } } @@ -243,6 +303,14 @@ func (m model) View() string { ui.PadText + m.transferProgress.View() + "\n\n" + ui.PadText + m.help.View(m.keys) + "\n\n" + case showOverwritePrompt: + waitingText := fmt.Sprintf("%s Waiting for file overwrite confirmation", m.spinner.View()) + return ui.PadText + ui.LogSeparator(m.width) + + ui.PadText + ui.InfoStyle(waitingText) + "\n\n" + + ui.PadText + m.transferProgress.View() + "\n\n" + + ui.PadText + m.overwritePrompt.View() + "\n\n" + + ui.PadText + m.help.View(m.keys) + "\n\n" + case showDecompressing: payloadSize := ui.BoldText(ui.ByteCountSI(m.payloadSize)) decompressingText := fmt.Sprintf("%s Decompressing payload (%s compressed) and writing to disk", m.spinner.View(), payloadSize) @@ -253,10 +321,10 @@ func (m model) View() string { case showFinished: oneOrMoreFiles := "object" - if len(m.receivedFiles) > 1 { + if len(m.receivedFiles) == 0 || len(m.receivedFiles) > 1 { oneOrMoreFiles += "s" } - finishedText := fmt.Sprintf("Received %d %s (%s compressed)", len(m.receivedFiles), oneOrMoreFiles, ui.ByteCountSI(m.payloadSize)) + finishedText := fmt.Sprintf("Received %d %s (%s decompressed)", len(m.receivedFiles), oneOrMoreFiles, ui.ByteCountSI(m.decompressedPayloadSize)) return ui.PadText + ui.LogSeparator(m.width) + ui.PadText + ui.InfoStyle(finishedText) + "\n\n" + ui.PadText + m.transferProgress.View() + "\n\n" + @@ -320,28 +388,57 @@ func listenReceiveCmd(msgs chan interface{}) tea.Cmd { } } -func decompressCmd(temp *os.File) tea.Cmd { +func (m *model) listenOverwritePromptRequestsCmd() tea.Cmd { + return func() tea.Msg { + return <-m.overwritePromptRequests + } +} + +func (m *model) decompressCmd(temp *os.File) tea.Cmd { return func() tea.Msg { - // reset file position for reading + // Reset file position for reading. _, err := temp.Seek(0, 0) if err != nil { return ui.ErrorMsg(err) } - filenames, decompressedSize, err := file.DecompressAndUnarchiveBytes(temp) + // promptFunc is a no-op if we allow overwriting files without prompts. + promptFunc := func(fileName string) (bool, error) { return true, nil } + if viper.GetBool("prompt_overwrite_files") { + promptFunc = func(fileName string) (bool, error) { + m.overwritePromptRequests <- overwritePromptRequestMsg{fileName} + overwritePromptResponse := <-m.overwritePromptResponses + return overwritePromptResponse.shouldOverwrite, nil + } + } + + fileNames, size, err := file.UnpackFiles(temp, promptFunc) if err != nil { return ui.ErrorMsg(err) } - return decompressionDoneMsg{filenames: filenames, decompressedPayloadSize: decompressedSize} + + return decompressionDoneMsg{fileNames, size} } } // -------------------- HELPER METHODS ------------------------- +func (m *model) newOverwritePrompt(fileName string) tea.Cmd { + prompt := confirmation.New(fmt.Sprintf("Overwrite file '%s'?", fileName), confirmation.Yes) + m.overwritePrompt = *confirmation.NewModel(prompt) + m.overwritePrompt.MaxWidth = m.width + m.overwritePrompt.WrapMode = promptkit.HardWrap + m.overwritePrompt.Template = confirmation.TemplateYN + m.overwritePrompt.ResultTemplate = confirmation.ResultTemplateYN + m.overwritePrompt.KeyMap.Abort = []string{} + m.overwritePrompt.KeyMap.Toggle = []string{} + return m.overwritePrompt.Init() +} + func (m *model) resetSpinner() { m.spinner = spinner.New() m.spinner.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(ui.ELEMENT_COLOR)) - if m.state == showEstablishing { + if m.state == showEstablishing || m.state == showOverwritePrompt { m.spinner.Spinner = ui.WaitingSpinner } if m.state == showDecompressing { diff --git a/ui/sender/sender.go b/ui/sender/sender.go index 656a484..8521dba 100644 --- a/ui/sender/sender.go +++ b/ui/sender/sender.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/SpatiumPortae/portal/internal/config" "github.com/SpatiumPortae/portal/internal/conn" "github.com/SpatiumPortae/portal/internal/file" "github.com/SpatiumPortae/portal/internal/semver" @@ -24,6 +25,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/pkg/errors" + "github.com/spf13/viper" "golang.org/x/exp/slices" ) @@ -250,7 +252,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.Quit): return m, tea.Quit case key.Matches(msg, m.keys.CopyPassword): - err := clipboard.WriteAll(fmt.Sprintf("portal receive %s", m.password)) + err := clipboard.WriteAll(m.copyReceiverCommand()) if err != nil { return m, ui.ErrorCmd(errors.New("Failed to copy password to clipboard")) } else { @@ -319,7 +321,7 @@ func (m model) View() string { return ui.PadText + ui.LogSeparator(m.width) + ui.PadText + ui.InfoStyle(statusText) + "\n\n" + ui.PadText + ui.InfoStyle("On the receiving end, run:") + "\n" + - ui.PadText + ui.InfoStyle(fmt.Sprintf("portal receive %s", m.password)) + "\n\n" + + ui.PadText + ui.InfoStyle(m.copyReceiverCommand()) + "\n\n" + m.fileTable.View() + ui.PadText + m.help.View(m.keys) + "\n\n" @@ -403,7 +405,12 @@ func readFilesCmd(paths []string) tea.Cmd { // provided files. func compressFilesCmd(files []*os.File) tea.Cmd { return func() tea.Msg { - tar, size, err := file.ArchiveAndCompressFiles(files) + defer func() { + for _, f := range files { + f.Close() + } + }() + tar, size, err := file.PackFiles(files) if err != nil { return ui.ErrorMsg(err) } @@ -429,7 +436,7 @@ func listenTransferCmd(msgs chan interface{}) tea.Cmd { } } -// -------------------- HELPER METHODS ------------------------- +// -------------------------------------------------- Helper Functions ------------------------------------------------- func (m *model) resetSpinner() { m.spinner = spinner.New() @@ -443,3 +450,19 @@ func (m *model) resetSpinner() { m.spinner.Spinner = ui.TransferSpinner } } + +func (m *model) copyReceiverCommand() string { + var builder strings.Builder + builder.WriteString("portal receive ") + builder.WriteString(m.password) + + relayAddrKey := "relay" + if !config.IsDefault(relayAddrKey) { + builder.WriteRune(' ') + builder.WriteString(fmt.Sprintf("--%s", relayAddrKey)) + builder.WriteRune(' ') + builder.WriteString(viper.GetString(relayAddrKey)) + } + + return builder.String() +}