From 2c1b6f38203253c4768d12ca78e41ec1cd812968 Mon Sep 17 00:00:00 2001 From: Arvid Gotthard <66034456+mellonnen@users.noreply.github.com> Date: Tue, 7 Mar 2023 14:34:25 +0000 Subject: [PATCH] Prompt refactor, and command restructuring (#58) --- .gitignore | 13 +- Makefile | 2 +- README.md | 7 +- cmd/portal/commands/config.go | 92 ++++++ cmd/portal/commands/helpers.go | 33 +++ cmd/portal/commands/receive.go | 222 +++++++++++++++ cmd/portal/commands/send.go | 125 +++++++++ cmd/portal/commands/serve.go | 36 +++ cmd/portal/commands/version.go | 20 ++ cmd/portal/config.go | 145 ---------- cmd/portal/config/config.go | 106 +++++++ cmd/portal/main.go | 75 ++--- cmd/portal/receive.go | 148 ---------- cmd/portal/send.go | 82 ------ cmd/portal/serve.go | 33 --- {ui => cmd/portal/tui}/constants.go | 2 +- {ui => cmd/portal/tui}/filetable/filetable.go | 24 +- {ui => cmd/portal/tui}/receiver/receiver.go | 261 +++++++++--------- {ui => cmd/portal/tui}/sender/sender.go | 170 ++++++------ .../tui}/transferprogress/transferprogress.go | 20 +- ui/ui.go => cmd/portal/tui/tui.go | 2 +- cmd/portal/validate.go | 46 --- cmd/wasm/main.go | 2 +- go.mod | 9 +- go.sum | 13 +- internal/config/config.go | 52 ---- internal/file/file.go | 210 ++++++++------ internal/password/password.go | 24 +- {portal => internal/portal}/config.go | 2 +- {portal => internal/portal}/portal.go | 3 - {portal => internal/portal}/portal_test.go | 2 +- internal/sender/sender.go | 24 +- 32 files changed, 1095 insertions(+), 910 deletions(-) create mode 100644 cmd/portal/commands/config.go create mode 100644 cmd/portal/commands/helpers.go create mode 100644 cmd/portal/commands/receive.go create mode 100644 cmd/portal/commands/send.go create mode 100644 cmd/portal/commands/serve.go create mode 100644 cmd/portal/commands/version.go delete mode 100644 cmd/portal/config.go create mode 100644 cmd/portal/config/config.go delete mode 100644 cmd/portal/receive.go delete mode 100644 cmd/portal/send.go delete mode 100644 cmd/portal/serve.go rename {ui => cmd/portal/tui}/constants.go (99%) rename {ui => cmd/portal/tui}/filetable/filetable.go (85%) rename {ui => cmd/portal/tui}/receiver/receiver.go (59%) rename {ui => cmd/portal/tui}/sender/sender.go (69%) rename {ui => cmd/portal/tui}/transferprogress/transferprogress.go (78%) rename ui/ui.go => cmd/portal/tui/tui.go (99%) delete mode 100644 cmd/portal/validate.go delete mode 100644 internal/config/config.go rename {portal => internal/portal}/config.go (95%) rename {portal => internal/portal}/portal.go (96%) rename {portal => internal/portal}/portal_test.go (97%) diff --git a/.gitignore b/.gitignore index 783a9e5..ccf03e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,14 @@ +# Ignore all +* + +# Unignore all with extensions +!*.* + +# Unignore all dirs +!*/ + +# Unignore Makefile +!Makefile # Portal log files .portal-*.log @@ -23,4 +34,4 @@ # misc .DS_Store dist/ -.vscode \ No newline at end of file +.vscode diff --git a/Makefile b/Makefile index 4687b3a..2a003e5 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ lint: golangci-lint run --timeout 5m ./... build: - go build -ldflags=${LINKER_FLAGS} -o portal-bin ./cmd/portal/ + go build -ldflags=${LINKER_FLAGS} -o portal ./cmd/portal/ build-production: CGO=0 go build -ldflags=${LINKER_FLAGS} -o portal ./cmd/portal/ diff --git a/README.md b/README.md index afb37ab..9c325b3 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ portal receive 42-relative-parsec-supernova #### `Sender` and `Receiver` - `-r/--relay`: address of the relay server (`:8080`, `myrelay.io:1234`, ...) +- `-s/--tui-style`: the style of the tui (`rich` | `raw`) #### `Sender`, `Receiver` and `Relay` @@ -143,9 +144,11 @@ As evident by the file extension, the config is a simple [YAML](https://yaml.org #### Default configuration ```yaml -relay: 167.71.65.96:80 +relay: portal.spatiumportae.com verbose: false prompt_overwrite_files: true +relay_serve_port: 8080 +tui_style: rich ``` ### Hosting your own relay @@ -231,4 +234,4 @@ The public relay available for everyone to use is.. -

\ No newline at end of file +

diff --git a/cmd/portal/commands/config.go b/cmd/portal/commands/config.go new file mode 100644 index 0000000..f2fa00d --- /dev/null +++ b/cmd/portal/commands/config.go @@ -0,0 +1,92 @@ +package commands + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/SpatiumPortae/portal/cmd/portal/config" + "github.com/alecthomas/chroma/quick" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func Config() *cobra.Command { + + pathCmd := &cobra.Command{ + Use: "path", + Short: "Output the path of the config file", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println(viper.ConfigFileUsed()) + }, + } + + viewCmd := &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 + }, + } + + editCmd := &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 + }, + } + resetCmd := &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.GetDefault().Yaml(), 0) + if err != nil { + return fmt.Errorf("config file (%s) could not be read/written to: %w", configPath, err) + } + return nil + }, + } + configCmd := &cobra.Command{ + Use: "config", + Short: "View and configure options", + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + ValidArgs: []string{pathCmd.Name(), viewCmd.Name(), editCmd.Name(), resetCmd.Name()}, + Run: func(cmd *cobra.Command, args []string) {}, + } + + configCmd.AddCommand(pathCmd) + configCmd.AddCommand(viewCmd) + configCmd.AddCommand(editCmd) + configCmd.AddCommand(resetCmd) + + return configCmd +} diff --git a/cmd/portal/commands/helpers.go b/cmd/portal/commands/helpers.go new file mode 100644 index 0000000..0911cb6 --- /dev/null +++ b/cmd/portal/commands/helpers.go @@ -0,0 +1,33 @@ +package commands + +import ( + "fmt" + "io" + "log" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/viper" +) + +const ( + relayFlagDesc = `Address of relay server. Accepted formats: + - 127.0.0.1:8080 + - [::1]:8080 + - somedomain.com/relay + - ... + ` + tuiStyleFlagDesc = "Style of the tui (rich|raw)" +) + +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: %w", err) + } + return f, nil + } + log.SetOutput(io.Discard) + return nil, nil +} diff --git a/cmd/portal/commands/receive.go b/cmd/portal/commands/receive.go new file mode 100644 index 0000000..8c0556d --- /dev/null +++ b/cmd/portal/commands/receive.go @@ -0,0 +1,222 @@ +package commands + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/SpatiumPortae/portal/cmd/portal/config" + receiver_tui "github.com/SpatiumPortae/portal/cmd/portal/tui/receiver" + "github.com/SpatiumPortae/portal/data" + "github.com/SpatiumPortae/portal/internal/file" + "github.com/SpatiumPortae/portal/internal/password" + "github.com/SpatiumPortae/portal/internal/portal" + "github.com/SpatiumPortae/portal/internal/semver" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "golang.org/x/exp/slices" +) + +// ------------------------------------------------------ Receive ------------------------------------------------------ + +func Receive(version string) *cobra.Command { + receiveCmd := &cobra.Command{ + Use: "receive", + Short: "Receive files", + Long: "The receive command receives files from the sender with the matching password.", + Args: cobra.ExactArgs(1), + ValidArgsFunction: passwordCompletion, + 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) + } + + if err := viper.BindPFlag("tui_style", cmd.Flags().Lookup("tui-style")); err != nil { + return fmt.Errorf("binding tui-style 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) + + logFile, err := setupLoggingFromViper("receive") + if err != nil { + return err + } + defer logFile.Close() + + pwd := args[0] + if !password.IsValid(pwd) { + return fmt.Errorf("invalid password format") + } + switch viper.GetString("tui_style") { + case config.StyleRich: + if err := handleReceiveCommand(version, pwd); err != nil { + return fmt.Errorf("running rich receive command: %w", err) + } + return nil + case config.StyleRaw: + if err := handleReceiveCommandRaw(version, pwd); err != nil { + return fmt.Errorf("running raw receive command: %w", err) + } + return nil + default: + return errors.New("invalid tui style provided") + } + }, + } + receiveCmd.Flags().StringP("relay", "r", "", relayFlagDesc) + receiveCmd.Flags().BoolP("yes", "y", false, "Overwrite existing files without [Y/n] prompts") + receiveCmd.Flags().StringP("tui-style", "s", "", tuiStyleFlagDesc) + return receiveCmd +} + +// ------------------------------------------------------ Handlers ----------------------------------------------------- + +// handleReceiveCommand is the receive application. +func handleReceiveCommand(version string, password string) error { + var opts []receiver_tui.Option + ver, err := semver.Parse(version) + if err == nil { + opts = append(opts, receiver_tui.WithVersion(ver)) + } + receiver := receiver_tui.New(viper.GetString("relay"), password, opts...) + + if _, err := receiver.Run(); err != nil { + return fmt.Errorf("running receiver tui: %w", err) + } + fmt.Println("") + return nil +} + +func handleReceiveCommandRaw(version string, password string) error { + ctx := context.Background() + relayAddr := viper.GetString("relay") + ver, err := semver.Parse(version) + if err != nil { + return fmt.Errorf("parsing version: %w", err) + } + serverVer, err := semver.GetRendezvousVersion(ctx, relayAddr) + if err != nil { + return fmt.Errorf("fetching version from relay: %w", err) + } + if ver.Compare(serverVer) == semver.CompareOldMajor { + return fmt.Errorf("incompatible version %s -> %s", ver, serverVer) + } + cnf := portal.Config{ + RendezvousAddr: relayAddr, + } + temp, err := os.CreateTemp(os.TempDir(), file.RECEIVE_TEMP_FILE_NAME_PREFIX) + if err != nil { + return fmt.Errorf("creating temp receiver file: %w", err) + } + + if err := portal.Receive(ctx, temp, password, &cnf); err != nil { + return fmt.Errorf("receiving files: %w", err) + } + + if _, err := temp.Seek(0, 0); err != nil { + return fmt.Errorf("seeking to start of temp file: %w", err) + } + unpacker, err := file.NewUnpacker(viper.GetBool("prompt_overwrite_files"), temp) + if err != nil { + return fmt.Errorf("creating unpacker: %w", err) + } + defer unpacker.Close() + defer file.RemoveTemporaryFiles(file.RECEIVE_TEMP_FILE_NAME_PREFIX) + + input := bufio.NewReader(os.Stdin) + for { + committer, err := unpacker.Unpack() + switch { + case errors.Is(err, io.EOF): + return nil + case errors.Is(err, file.ErrUnpackFileExists): + fmt.Printf("overwrite %s? [y/n] ", committer.FileName()) + response, err := input.ReadString('\n') + if err != nil { + return fmt.Errorf("unable to read input from stdin: %w", err) + } + switch strings.TrimSpace(response) { + case "y", "yes", "Y", "Yes", "": + // falltrough to commit. + case "n", "no", "N", "No": + continue + default: + return errors.New("invalid response to prompt") + } + case err != nil: + return fmt.Errorf("unpacking file: %w", err) + } + if _, err := committer.Commit(); err != nil { + return fmt.Errorf("committing file %s to disk: %w", committer.FileName(), err) + } + } +} + +// ------------------------------------------------ Password Completion ------------------------------------------------ + +func passwordCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + components := strings.Split(toComplete, "-") + + if len(components) > password.Length+1 || len(components) == 0 { + return nil, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp + } + if len(components) == 1 { + if _, err := strconv.Atoi(components[0]); err != nil { + return nil, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp + } + return []string{fmt.Sprintf("%s-", components[0])}, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp + } + // Remove previous components of password, and filter based on prefix. + suggs := filterPrefix(removeElems(data.SpaceWordList, components[:len(components)-1]), components[len(components)-1]) + var res []string + for _, sugg := range suggs { + components := append(components[:len(components)-1], sugg) + pw := strings.Join(components, "-") + if len(components) <= password.Length { + pw += "-" + } + res = append(res, pw) + } + return res, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp +} + +func removeElems(src []string, elems []string) []string { + var res []string + for _, elem := range src { + if slices.Contains(elems, elem) { + continue + } + res = append(res, elem) + } + return res +} + +func filterPrefix(src []string, prefix string) []string { + var res []string + for _, elem := range src { + if strings.HasPrefix(elem, prefix) { + res = append(res, elem) + } + } + return res +} diff --git a/cmd/portal/commands/send.go b/cmd/portal/commands/send.go new file mode 100644 index 0000000..1db6015 --- /dev/null +++ b/cmd/portal/commands/send.go @@ -0,0 +1,125 @@ +package commands + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/SpatiumPortae/portal/cmd/portal/config" + sender_ui "github.com/SpatiumPortae/portal/cmd/portal/tui/sender" + "github.com/SpatiumPortae/portal/internal/file" + "github.com/SpatiumPortae/portal/internal/portal" + "github.com/SpatiumPortae/portal/internal/semver" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// -------------------------------------------------------- Send ------------------------------------------------------- + +func Send(version string) *cobra.Command { + sendCmd := &cobra.Command{ + Use: "send file1 file2...", + 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), + PreRunE: func(cmd *cobra.Command, args []string) error { + if err := viper.BindPFlag("relay", cmd.Flags().Lookup("relay")); err != nil { + return fmt.Errorf("binding relay flag: %w", err) + } + if err := viper.BindPFlag("tui_style", cmd.Flags().Lookup("tui-style")); err != nil { + return fmt.Errorf("binding tui-style flag: %w", err) + } + return nil + + }, + RunE: func(cmd *cobra.Command, args []string) error { + file.RemoveTemporaryFiles(file.SEND_TEMP_FILE_NAME_PREFIX) + + logFile, err := setupLoggingFromViper("send") + if err != nil { + return err + } + defer logFile.Close() + switch viper.GetString("tui_style") { + case config.StyleRich: + if err := handleSendCommand(version, args); err != nil { + return fmt.Errorf("running rich send command: %w", err) + } + case config.StyleRaw: + if err := handleSendCommandRaw(version, args); err != nil { + return fmt.Errorf("running raw send command: %w", err) + } + default: + return errors.New("invalid tui style provided") + } + return nil + }, + } + sendCmd.Flags().StringP("relay", "r", "", relayFlagDesc) + sendCmd.Flags().StringP("tui-style", "s", "", tuiStyleFlagDesc) + return sendCmd +} + +// ------------------------------------------------------ Handlers ----------------------------------------------------- + +// handleSendCommand is the sender application. +func handleSendCommand(version string, fileNames []string) error { + var opts []sender_ui.Option + ver, err := semver.Parse(version) + // Conditionally add option to sender ui + if err == nil { + opts = append(opts, sender_ui.WithVersion(ver)) + } + relayAddr := viper.GetString("relay") + sender := sender_ui.New(fileNames, relayAddr, opts...) + if _, err := sender.Run(); err != nil { + return fmt.Errorf("running tui: %w", err) + } + fmt.Println("") + return nil +} + +func handleSendCommandRaw(version string, filenames []string) error { + ctx := context.Background() + relayAddr := viper.GetString("relay") + ver, err := semver.Parse(version) + if err != nil { + return fmt.Errorf("parsing version: %w", err) + } + serverVer, err := semver.GetRendezvousVersion(ctx, relayAddr) + if err != nil { + return fmt.Errorf("fetching version from relay: %w", err) + } + if ver.Compare(serverVer) == semver.CompareOldMajor { + return fmt.Errorf("incompatible version %s -> %s", ver, serverVer) + } + files := make([]*os.File, 0, len(filenames)) + for _, name := range filenames { + f, err := os.Open(name) + if err != nil { + return fmt.Errorf("unable to open file %q: %w", name, err) + } + defer f.Close() + files = append(files, f) + } + payload, size, err := file.PackFiles(files) + if err != nil { + return fmt.Errorf("error packing files: %w", err) + } + defer payload.Close() + defer file.RemoveTemporaryFiles(file.SEND_TEMP_FILE_NAME_PREFIX) + cnf := portal.Config{ + RendezvousAddr: relayAddr, + } + password, err, errC := portal.Send(ctx, payload, size, &cnf) + if err != nil { + return fmt.Errorf("doing initial handshake: %w", err) + } + fmt.Println(password) + err = <-errC + if err != nil { + return fmt.Errorf("doing portal transfer: %w", err) + } + return nil +} diff --git a/cmd/portal/commands/serve.go b/cmd/portal/commands/serve.go new file mode 100644 index 0000000..43c9b86 --- /dev/null +++ b/cmd/portal/commands/serve.go @@ -0,0 +1,36 @@ +package commands + +import ( + "fmt" + + "github.com/SpatiumPortae/portal/internal/rendezvous" + "github.com/SpatiumPortae/portal/internal/semver" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func Serve(version string) *cobra.Command { + serveCmd := &cobra.Command{ + Use: "serve", + Short: "Serve the relay server", + Long: "The serve command serves the relay server locally.", + Args: cobra.MatchAll(cobra.ExactArgs(0), cobra.NoArgs), + PreRunE: func(cmd *cobra.Command, args []string) error { + if err := viper.BindPFlag("relay_serve_port", cmd.Flags().Lookup("port")); err != nil { + return fmt.Errorf("binding relay-port flag: %w", err) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + ver, err := semver.Parse(version) + if err != nil { + return fmt.Errorf("server requires version to be set: %w", err) + } + server := rendezvous.NewServer(viper.GetInt("relay_serve_port"), ver) + server.Start() + return nil + }, + } + serveCmd.Flags().IntP("port", "p", 0, "port to run the portal relay server on") + return serveCmd +} diff --git a/cmd/portal/commands/version.go b/cmd/portal/commands/version.go new file mode 100644 index 0000000..643a33b --- /dev/null +++ b/cmd/portal/commands/version.go @@ -0,0 +1,20 @@ +package commands + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +func Version(version string) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Display the installed version of portal", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println(version) + os.Exit(0) + }, + } + +} diff --git a/cmd/portal/config.go b/cmd/portal/config.go deleted file mode 100644 index 1eefeb6..0000000 --- a/cmd/portal/config.go +++ /dev/null @@ -1,145 +0,0 @@ -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/config/config.go b/cmd/portal/config/config.go new file mode 100644 index 0000000..3a4ed9d --- /dev/null +++ b/cmd/portal/config/config.go @@ -0,0 +1,106 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/fatih/structs" + "github.com/mitchellh/go-homedir" + "github.com/spf13/viper" +) + +const ( + CONFIGS_DIR_NAME = ".config" + PORTAL_CONFIG_DIR_NAME = "portal" + CONFIG_FILE_NAME = "config" + CONFIG_FILE_EXT = "yml" + + StyleRich = "rich" + StyleRaw = "raw" +) + +type Config struct { + Relay string `mapstructure:"relay"` + Verbose bool `mapstructure:"verbose"` + PromptOverwriteFiles bool `mapstructure:"prompt_overwrite_files"` + RelayServePort int `mapstructure:"relay_serve_port"` + TuiStyle string `mapstructure:"tui_style"` +} + +func GetDefault() Config { + return Config{ + Relay: "portal.spatiumportae.com", + Verbose: false, + PromptOverwriteFiles: true, + RelayServePort: 8080, + TuiStyle: StyleRich, + } +} + +func (config Config) Map() 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 (config Config) Yaml() []byte { + var builder strings.Builder + for k, v := range config.Map() { + builder.WriteString(fmt.Sprintf("%s: %v", k, v)) + builder.WriteRune('\n') + } + return []byte(builder.String()) +} + +func IsDefault(key string) bool { + defaults := GetDefault().Map() + return viper.Get(key) == defaults[key] +} + +// Init 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 Init() error { + home, err := homedir.Dir() + if err != nil { + return fmt.Errorf("resolving home dir: %w", err) + } + + configPath := filepath.Join(home, CONFIGS_DIR_NAME, PORTAL_CONFIG_DIR_NAME) + viper.AddConfigPath(configPath) + viper.SetConfigName(CONFIG_FILE_NAME) + viper.SetConfigType(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 { + return fmt.Errorf("Could not create config directory: %w", err) + } + + configFile, err := os.Create(filepath.Join(configPath, fmt.Sprintf("%s.%s", CONFIG_FILE_NAME, CONFIG_FILE_EXT))) + if err != nil { + return fmt.Errorf("Could not create config file: %w", err) + } + defer configFile.Close() + + _, err = configFile.Write(GetDefault().Yaml()) + if err != nil { + return fmt.Errorf("Could not write defaults to config file: %w", err) + } + } else { + return fmt.Errorf("Could not read config file: %w", err) + } + } + for k, v := range GetDefault().Map() { + viper.SetDefault(k, v) + } + return nil +} diff --git a/cmd/portal/main.go b/cmd/portal/main.go index fc5c411..268bc3d 100644 --- a/cmd/portal/main.go +++ b/cmd/portal/main.go @@ -2,11 +2,10 @@ package main import ( "fmt" - "io" - "log" "os" - tea "github.com/charmbracelet/bubbletea" + "github.com/SpatiumPortae/portal/cmd/portal/commands" + "github.com/SpatiumPortae/portal/cmd/portal/config" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -15,58 +14,40 @@ import ( // injected at link time using -ldflags. var version string -// Initialization of cobra and viper. -func init() { - initConfig() - setDefaults() +// -------------------------------------------------------- Root ------------------------------------------------------- +func Root() (*cobra.Command, error) { + if err := config.Init(); err != nil { + return nil, fmt.Errorf("initializing config: %w", err) + } + rootCmd := &cobra.Command{ + Use: "portal", + Short: "Portal is a quick and easy command-line file transfer utility from any computer to another.", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + //nolint:errcheck + viper.BindPFlag("verbose", cmd.Flags().Lookup("verbose")) + }, + } 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", - Short: "Portal is a quick and easy command-line file transfer utility from any computer to another.", - PersistentPreRun: func(cmd *cobra.Command, args []string) { - //nolint:errcheck - viper.BindPFlag("verbose", cmd.Flags().Lookup("verbose")) - }, + rootCmd.AddCommand( + commands.Send(version), + commands.Receive(version), + commands.Serve(version), + commands.Version(version), + commands.Config()) + return rootCmd, nil } -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Display the installed version of portal", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println(version) - os.Exit(0) - }, -} +// ------------------------------------------------------- Runner ------------------------------------------------------ // Entry point of the application. func main() { - if err := rootCmd.Execute(); err != nil { + rootCmd, err := Root() + if err != nil { + fmt.Println(err) os.Exit(1) } -} - -// -------------------------------------------------- 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: %w", err) - } - return f, nil + if err := rootCmd.Execute(); err != nil { + os.Exit(1) } - log.SetOutput(io.Discard) - return nil, nil } diff --git a/cmd/portal/receive.go b/cmd/portal/receive.go deleted file mode 100644 index e0a04cb..0000000 --- a/cmd/portal/receive.go +++ /dev/null @@ -1,148 +0,0 @@ -package main - -import ( - "fmt" - "os" - "strconv" - "strings" - - "github.com/SpatiumPortae/portal/data" - "github.com/SpatiumPortae/portal/internal/file" - "github.com/SpatiumPortae/portal/internal/password" - "github.com/SpatiumPortae/portal/internal/semver" - "github.com/SpatiumPortae/portal/ui/receiver" - "github.com/spf13/cobra" - "github.com/spf13/viper" - "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", - Short: "Receive files", - Long: "The receive command receives files from the sender with the matching password.", - Args: cobra.ExactArgs(1), - ValidArgsFunction: passwordCompletion, - 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) - - relayAddr := viper.GetString("relay") - if err := validateAddress(relayAddr); err != nil { - return fmt.Errorf("%w: (%s) is not a valid relay address", err, relayAddr) - } - - logFile, err := setupLoggingFromViper("receive") - if err != nil { - return err - } - defer logFile.Close() - - pwd := args[0] - if !password.IsValid(pwd) { - return fmt.Errorf("invalid password format") - } - handleReceiveCommand(pwd) - return nil - }, -} - -// ------------------------------------------------------ Handler ------------------------------------------------------ - -// handleReceiveCommand is the receive application. -func handleReceiveCommand(password string) { - var opts []receiver.Option - ver, err := semver.Parse(version) - if err == nil { - opts = append(opts, receiver.WithVersion(ver)) - } - receiver := receiver.New(viper.GetString("relay"), password, opts...) - - if _, err := receiver.Run(); err != nil { - fmt.Println("Error initializing UI", err) - os.Exit(1) - } - fmt.Println("") - os.Exit(0) -} - -// ------------------------------------------------ Password Completion ------------------------------------------------ - -func passwordCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - components := strings.Split(toComplete, "-") - - if len(components) > password.Length+1 || len(components) == 0 { - return nil, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp - } - if len(components) == 1 { - if _, err := strconv.Atoi(components[0]); err != nil { - return nil, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp - } - return []string{fmt.Sprintf("%s-", components[0])}, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp - } - // Remove previous components of password, and filter based on prefix. - suggs := filterPrefix(removeElems(data.SpaceWordList, components[:len(components)-1]), components[len(components)-1]) - var res []string - for _, sugg := range suggs { - components := append(components[:len(components)-1], sugg) - pw := strings.Join(components, "-") - if len(components) <= password.Length { - pw += "-" - } - res = append(res, pw) - } - return res, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp -} - -func removeElems(src []string, elems []string) []string { - var res []string - for _, elem := range src { - if slices.Contains(elems, elem) { - continue - } - res = append(res, elem) - } - return res -} - -func filterPrefix(src []string, prefix string) []string { - var res []string - for _, elem := range src { - if strings.HasPrefix(elem, prefix) { - res = append(res, elem) - } - } - return res -} diff --git a/cmd/portal/send.go b/cmd/portal/send.go deleted file mode 100644 index 432824d..0000000 --- a/cmd/portal/send.go +++ /dev/null @@ -1,82 +0,0 @@ -package main - -import ( - "fmt" - "os" - - "github.com/SpatiumPortae/portal/internal/file" - "github.com/SpatiumPortae/portal/internal/semver" - "github.com/SpatiumPortae/portal/internal/sender" - senderui "github.com/SpatiumPortae/portal/ui/sender" - "github.com/spf13/cobra" - "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), - 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) - } - 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) - - relayAddr := viper.GetString("relay") - if err := validateAddress(relayAddr); err != nil { - return fmt.Errorf("%w: (%s) is not a valid relay address", err, relayAddr) - } - - logFile, err := setupLoggingFromViper("send") - if err != nil { - return err - } - defer logFile.Close() - - handleSendCommand(args) - return nil - }, -} - -// ------------------------------------------------------ Handler ------------------------------------------------------ - -// handleSendCommand is the sender application. -func handleSendCommand(fileNames []string) { - var opts []senderui.Option - ver, err := semver.Parse(version) - // Conditionally add option to sender ui - if err == nil { - opts = append(opts, senderui.WithVersion(ver)) - } - 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) - } - fmt.Println("") - os.Exit(0) -} diff --git a/cmd/portal/serve.go b/cmd/portal/serve.go deleted file mode 100644 index 196c63d..0000000 --- a/cmd/portal/serve.go +++ /dev/null @@ -1,33 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/SpatiumPortae/portal/internal/rendezvous" - "github.com/SpatiumPortae/portal/internal/semver" - "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 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) - if err != nil { - return fmt.Errorf("server requires version to be set: %w", err) - } - server := rendezvous.NewServer(port, ver) - server.Start() - return nil - }, -} diff --git a/ui/constants.go b/cmd/portal/tui/constants.go similarity index 99% rename from ui/constants.go rename to cmd/portal/tui/constants.go index 97afd17..66ab3f3 100644 --- a/ui/constants.go +++ b/cmd/portal/tui/constants.go @@ -1,4 +1,4 @@ -package ui +package tui import ( "strings" diff --git a/ui/filetable/filetable.go b/cmd/portal/tui/filetable/filetable.go similarity index 85% rename from ui/filetable/filetable.go rename to cmd/portal/tui/filetable/filetable.go index 82f3d3a..911dcb6 100644 --- a/ui/filetable/filetable.go +++ b/cmd/portal/tui/filetable/filetable.go @@ -3,8 +3,8 @@ package filetable import ( "math" + "github.com/SpatiumPortae/portal/cmd/portal/tui" "github.com/SpatiumPortae/portal/internal/file" - "github.com/SpatiumPortae/portal/ui" "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -17,10 +17,10 @@ const ( sizeColumnWidthFactor float64 = 1 - nameColumnWidthFactor ) -var fileTableStyle = ui.BaseStyle.Copy(). +var fileTableStyle = tui.BaseStyle.Copy(). BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color(ui.SECONDARY_COLOR)). - MarginLeft(ui.MARGIN) + BorderForeground(lipgloss.Color(tui.SECONDARY_COLOR)). + MarginLeft(tui.MARGIN) type Option func(m *Model) @@ -49,12 +49,12 @@ func New(opts ...Option) Model { s := table.DefaultStyles() s.Header = s.Header. BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color(ui.SECONDARY_COLOR)). + BorderForeground(lipgloss.Color(tui.SECONDARY_COLOR)). BorderBottom(true). Bold(true) s.Selected = s.Selected. - Foreground(lipgloss.Color(ui.DARK_COLOR)). - Background(lipgloss.Color(ui.SECONDARY_ELEMENT_COLOR)). + Foreground(lipgloss.Color(tui.DARK_COLOR)). + Background(lipgloss.Color(tui.SECONDARY_ELEMENT_COLOR)). Bold(false) m.tableStyles = s m.table.SetStyles(m.tableStyles) @@ -74,7 +74,7 @@ func (m *Model) SetFiles(filePaths []string) { if err != nil { formattedSize = "N/A" } else { - formattedSize = ui.ByteCountSI(size) + formattedSize = tui.ByteCountSI(size) } m.rows = append(m.rows, fileRow{path: filePath, formattedSize: formattedSize}) } @@ -101,7 +101,7 @@ func WithMaxHeight(height int) Option { } func (m *Model) getMaxWidth() int { - return int(math.Min(ui.MAX_WIDTH-2*ui.MARGIN, float64(m.Width))) + return int(math.Min(tui.MAX_WIDTH-2*tui.MARGIN, float64(m.Width))) } func (m *Model) updateColumns() { @@ -147,9 +147,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: - m.Width = msg.Width - 2*ui.MARGIN - 4 - if m.Width > ui.MAX_WIDTH { - m.Width = ui.MAX_WIDTH + m.Width = msg.Width - 2*tui.MARGIN - 4 + if m.Width > tui.MAX_WIDTH { + m.Width = tui.MAX_WIDTH } m.updateColumns() m.updateRows() diff --git a/ui/receiver/receiver.go b/cmd/portal/tui/receiver/receiver.go similarity index 59% rename from ui/receiver/receiver.go rename to cmd/portal/tui/receiver/receiver.go index f5fd242..6e805f3 100644 --- a/ui/receiver/receiver.go +++ b/cmd/portal/tui/receiver/receiver.go @@ -4,18 +4,19 @@ import ( "context" "errors" "fmt" + "io" "math" "os" "time" + "github.com/SpatiumPortae/portal/cmd/portal/tui" + "github.com/SpatiumPortae/portal/cmd/portal/tui/filetable" + "github.com/SpatiumPortae/portal/cmd/portal/tui/transferprogress" "github.com/SpatiumPortae/portal/internal/conn" "github.com/SpatiumPortae/portal/internal/file" "github.com/SpatiumPortae/portal/internal/receiver" "github.com/SpatiumPortae/portal/internal/semver" "github.com/SpatiumPortae/portal/protocol/transfer" - "github.com/SpatiumPortae/portal/ui" - "github.com/SpatiumPortae/portal/ui/filetable" - "github.com/SpatiumPortae/portal/ui/transferprogress" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" @@ -26,12 +27,12 @@ import ( "github.com/spf13/viper" ) -// ------------------------------------------------------ Ui State ----------------------------------------------------- -type uiState int +// ------------------------------------------------------ tui State ----------------------------------------------------- +type tuiState int // Flows from the top down. const ( - showEstablishing uiState = iota + showEstablishing tuiState = iota showReceivingProgress showDecompressing showOverwritePrompt @@ -51,17 +52,13 @@ type receiveDoneMsg struct { temp *os.File } -type overwritePromptRequestMsg struct { - fileName string +type unpackDoneMsg struct{} +type unpackPromptMsg struct { + commiter file.Committer } - -type overwritePromptResponseMsg struct { - shouldOverwrite bool -} - -type decompressionDoneMsg struct { - fileNames []string - decompressedPayloadSize int64 +type commitMsg struct { + size int64 + name string } // ------------------------------------------------------- Model ------------------------------------------------------- @@ -75,14 +72,12 @@ func WithVersion(version semver.Version) Option { } type model struct { - state uiState + state tuiState transferType transfer.Type password string - ctx context.Context - msgs chan interface{} - overwritePromptRequests chan overwritePromptRequestMsg - overwritePromptResponses chan overwritePromptResponseMsg + ctx context.Context + msgs chan interface{} rendezvousAddr string @@ -91,29 +86,30 @@ type model struct { decompressedPayloadSize int64 version *semver.Version + unpacker *file.Unpacker + commiter file.Committer + width int spinner spinner.Model transferProgress transferprogress.Model fileTable filetable.Model overwritePrompt confirmation.Model help help.Model - keys ui.KeyMap + keys tui.KeyMap } // 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), - 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(), + transferProgress: transferprogress.New(), + msgs: make(chan interface{}, 10), + password: password, + rendezvousAddr: addr, + fileTable: filetable.New(), + overwritePrompt: *confirmation.NewModel(confirmation.New("", confirmation.Undecided)), + help: help.New(), + keys: tui.Keys, + ctx: context.Background(), } for _, opt := range opts { opt(&m) @@ -125,38 +121,38 @@ func New(addr string, password string, opts ...Option) *tea.Program { func (m model) Init() tea.Cmd { var versionCmd tea.Cmd if m.version != nil { - versionCmd = ui.VersionCmd(m.ctx, m.rendezvousAddr) + versionCmd = tui.VersionCmd(m.ctx, m.rendezvousAddr) } return tea.Sequence(versionCmd, tea.Batch(m.spinner.Tick, connectCmd(m.rendezvousAddr))) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case ui.VersionMsg: + case tui.VersionMsg: var message string switch m.version.Compare(msg.ServerVersion) { case semver.CompareNewMajor, semver.CompareOldMajor: - //lint:ignore ST1005 error string displayed in UI - return m, ui.ErrorCmd(fmt.Errorf("Portal version (%s) incompatible with server version (%s)", m.version, msg.ServerVersion)) + //lint:ignore ST1005 error string displayed in tui + return m, tui.ErrorCmd(fmt.Errorf("Portal version (%s) incompatible with server version (%s)", m.version, msg.ServerVersion)) case semver.CompareNewMinor, semver.CompareNewPatch: - message = ui.WarningText(fmt.Sprintf("Portal version (%s) newer than server version (%s)", m.version, msg.ServerVersion)) + message = tui.WarningText(fmt.Sprintf("Portal version (%s) newer than server version (%s)", m.version, msg.ServerVersion)) case semver.CompareOldMinor, semver.CompareOldPatch: - message = ui.WarningText(fmt.Sprintf("Server version (%s) newer than Portal version (%s)", msg.ServerVersion, m.version)) + message = tui.WarningText(fmt.Sprintf("Server version (%s) newer than Portal version (%s)", msg.ServerVersion, m.version)) case semver.CompareEqual: - message = ui.SuccessText(fmt.Sprintf("Portal version (%s) compatible with server version (%s)", m.version, msg.ServerVersion)) + message = tui.SuccessText(fmt.Sprintf("Portal version (%s) compatible with server version (%s)", m.version, msg.ServerVersion)) } - return m, ui.TaskCmd(message, nil) + return m, tui.TaskCmd(message, nil) case connectMsg: message := fmt.Sprintf("Connected to Portal server (%s)", m.rendezvousAddr) - return m, ui.TaskCmd(message, secureCmd(m.ctx, msg.conn, m.password)) + return m, tui.TaskCmd(message, secureCmd(m.ctx, msg.conn, m.password)) - case ui.SecureMsg: + case tui.SecureMsg: message := "Established encrypted connection to sender" - return m, ui.TaskCmd(message, + return m, tui.TaskCmd(message, tea.Batch(listenReceiveCmd(m.msgs), receiveCmd(m.ctx, msg.Conn, m.msgs))) case payloadSizeMsg: @@ -164,7 +160,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.transferProgress.PayloadSize = msg.size return m, listenReceiveCmd(m.msgs) - case ui.TransferTypeMsg: + case tui.TransferTypeMsg: var message string m.transferType = msg.Type switch m.transferType { @@ -173,9 +169,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case transfer.Relay: message = "Using relayed connection to sender" } - return m, ui.TaskCmd(message, listenReceiveCmd(m.msgs)) + return m, tui.TaskCmd(message, listenReceiveCmd(m.msgs)) - case ui.ProgressMsg: + case tui.ProgressMsg: cmds := []tea.Cmd{listenReceiveCmd(m.msgs)} if m.state != showReceivingProgress { m.state = showReceivingProgress @@ -194,38 +190,42 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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), + tui.ByteCountSI(m.transferProgress.TransferSpeedEstimateBps), ) m.fileTable.SetMaxHeight(math.MaxInt) m.fileTable = m.fileTable.Finalize().(filetable.Model) - cmds := []tea.Cmd{m.spinner.Tick, - m.listenOverwritePromptRequestsCmd(), - m.decompressCmd(msg.temp), + var err error + m.unpacker, err = file.NewUnpacker(viper.GetBool("prompt_overwrite_files"), msg.temp) + if err != nil { + return m, tui.ErrorCmd(err) } - return m, ui.TaskCmd(message, tea.Batch(cmds...)) + return m, tui.TaskCmd(message, tea.Batch(m.spinner.Tick, m.unpackCmd())) - case overwritePromptRequestMsg: + case commitMsg: + m.receivedFiles = append(m.receivedFiles, msg.name) + m.decompressedPayloadSize += msg.size + return m, m.unpackCmd() + + case unpackPromptMsg: m.state = showOverwritePrompt + m.commiter = msg.commiter 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.commiter.FileName())) - return m, tea.Batch(m.spinner.Tick, m.newOverwritePrompt(msg.fileName)) - - case decompressionDoneMsg: + case unpackDoneMsg: + m.unpacker.Close() m.state = showFinished - m.receivedFiles = msg.fileNames - m.decompressedPayloadSize = msg.decompressedPayloadSize - m.fileTable.SetFiles(m.receivedFiles) - return m, ui.QuitCmd() + return m, tui.QuitCmd() - case ui.ErrorMsg: - return m, ui.ErrorCmd(errors.New(msg.Error())) + case tui.ErrorMsg: + return m, tui.ErrorCmd(errors.New(msg.Error())) case tea.KeyMsg: var cmds []tea.Cmd @@ -251,11 +251,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.keys.OverwritePromptNo.SetEnabled(false) m.keys.OverwritePromptConfirm.SetEnabled(false) shouldOverwrite, _ := m.overwritePrompt.Value() - m.overwritePromptResponses <- overwritePromptResponseMsg{shouldOverwrite} - cmds = append(cmds, m.listenOverwritePromptRequestsCmd()) + if shouldOverwrite { + cmds = append(cmds, m.commitCmd()) + } else { + cmds = append(cmds, m.unpackCmd()) + } } } - return m, tea.Batch(cmds...) case tea.WindowSizeMsg: @@ -266,7 +268,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { fileTableModel, fileTableCmd := m.fileTable.Update(msg) m.fileTable = fileTableModel.(filetable.Model) - m.overwritePrompt.MaxWidth = msg.Width - 2*ui.MARGIN - 4 + m.overwritePrompt.MaxWidth = msg.Width - 2*tui.MARGIN - 4 _, promptCmd := m.overwritePrompt.Update(msg) return m, tea.Batch(transferProgressCmd, fileTableCmd, promptCmd) @@ -284,9 +286,9 @@ func (m model) View() string { switch m.state { case showEstablishing: - return ui.PadText + ui.LogSeparator(m.width) + - ui.PadText + ui.InfoStyle(fmt.Sprintf("%s Establishing connection with sender", m.spinner.View())) + "\n\n" + - ui.PadText + m.help.View(m.keys) + "\n\n" + return tui.PadText + tui.LogSeparator(m.width) + + tui.PadText + tui.InfoStyle(fmt.Sprintf("%s Establishing connection with sender", m.spinner.View())) + "\n\n" + + tui.PadText + m.help.View(m.keys) + "\n\n" case showReceivingProgress: var transferType string @@ -296,38 +298,38 @@ func (m model) View() string { transferType = "relayed" } - payloadSize := ui.BoldText(ui.ByteCountSI(m.payloadSize)) + payloadSize := tui.BoldText(tui.ByteCountSI(m.payloadSize)) receivingText := fmt.Sprintf("%s Receiving objects (%s) using %s transfer", m.spinner.View(), payloadSize, transferType) - return ui.PadText + ui.LogSeparator(m.width) + - ui.PadText + ui.InfoStyle(receivingText) + "\n\n" + - ui.PadText + m.transferProgress.View() + "\n\n" + - ui.PadText + m.help.View(m.keys) + "\n\n" + return tui.PadText + tui.LogSeparator(m.width) + + tui.PadText + tui.InfoStyle(receivingText) + "\n\n" + + tui.PadText + m.transferProgress.View() + "\n\n" + + tui.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" + return tui.PadText + tui.LogSeparator(m.width) + + tui.PadText + tui.InfoStyle(waitingText) + "\n\n" + + tui.PadText + m.transferProgress.View() + "\n\n" + + tui.PadText + m.overwritePrompt.View() + "\n\n" + + tui.PadText + m.help.View(m.keys) + "\n\n" case showDecompressing: - payloadSize := ui.BoldText(ui.ByteCountSI(m.payloadSize)) + payloadSize := tui.BoldText(tui.ByteCountSI(m.payloadSize)) decompressingText := fmt.Sprintf("%s Decompressing payload (%s compressed) and writing to disk", m.spinner.View(), payloadSize) - return ui.PadText + ui.LogSeparator(m.width) + - ui.PadText + ui.InfoStyle(decompressingText) + "\n\n" + - ui.PadText + m.transferProgress.View() + "\n\n" + - ui.PadText + m.help.View(m.keys) + "\n\n" + return tui.PadText + tui.LogSeparator(m.width) + + tui.PadText + tui.InfoStyle(decompressingText) + "\n\n" + + tui.PadText + m.transferProgress.View() + "\n\n" + + tui.PadText + m.help.View(m.keys) + "\n\n" case showFinished: oneOrMoreFiles := "object" if len(m.receivedFiles) == 0 || len(m.receivedFiles) > 1 { oneOrMoreFiles += "s" } - 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" + + finishedText := fmt.Sprintf("Received %d %s (%s decompressed)", len(m.receivedFiles), oneOrMoreFiles, tui.ByteCountSI(m.decompressedPayloadSize)) + return tui.PadText + tui.LogSeparator(m.width) + + tui.PadText + tui.InfoStyle(finishedText) + "\n\n" + + tui.PadText + m.transferProgress.View() + "\n\n" + m.fileTable.View() default: @@ -335,13 +337,13 @@ func (m model) View() string { } } -// -------------------- UI COMMANDS --------------------------- +// ------------------------------------------------------ Commands ----------------------------------------------------- func connectCmd(addr string) tea.Cmd { return func() tea.Msg { rc, err := receiver.ConnectRendezvous(addr) if err != nil { - return ui.ErrorMsg(err) + return tui.ErrorMsg(err) } return connectMsg{conn: rc} } @@ -351,9 +353,9 @@ func secureCmd(ctx context.Context, rc conn.Rendezvous, password string) tea.Cmd return func() tea.Msg { tc, err := receiver.SecureConnection(ctx, rc, password) if err != nil { - return ui.ErrorMsg(err) + return tui.ErrorMsg(err) } - return ui.SecureMsg{Conn: tc} + return tui.SecureMsg{Conn: tc} } } @@ -361,10 +363,13 @@ func receiveCmd(ctx context.Context, tc conn.Transfer, msgs ...chan interface{}) return func() tea.Msg { temp, err := os.CreateTemp(os.TempDir(), file.RECEIVE_TEMP_FILE_NAME_PREFIX) if err != nil { - return ui.ErrorMsg(err) + return tui.ErrorMsg(err) } if err := receiver.Receive(ctx, tc, temp, msgs...); err != nil { - return ui.ErrorMsg(err) + return tui.ErrorMsg(err) + } + if _, err := temp.Seek(0, 0); err != nil { + return tui.ErrorMsg(err) } return receiveDoneMsg{temp: temp} } @@ -375,11 +380,11 @@ func listenReceiveCmd(msgs chan interface{}) tea.Cmd { msg := <-msgs switch v := msg.(type) { case transfer.Type: - return ui.TransferTypeMsg{Type: v} + return tui.TransferTypeMsg{Type: v} case transfer.MsgType: - return ui.TransferStateMessage{State: v} + return tui.TransferStateMessage{State: v} case int: - return ui.ProgressMsg(v) + return tui.ProgressMsg(v) case int64: return payloadSizeMsg{size: v} default: @@ -388,40 +393,48 @@ func listenReceiveCmd(msgs chan interface{}) tea.Cmd { } } -func (m *model) listenOverwritePromptRequestsCmd() tea.Cmd { +func (m *model) unpackCmd() tea.Cmd { return func() tea.Msg { - return <-m.overwritePromptRequests + commiter, err := m.unpacker.Unpack() + switch { + case errors.Is(err, io.EOF): + return unpackDoneMsg{} + case errors.Is(err, file.ErrUnpackFileExists): + return unpackPromptMsg{ + commiter: commiter, + } + case err != nil: + return tui.ErrorMsg(err) + } + size, err := commiter.Commit() + if err != nil { + return tui.ErrorMsg(err) + } + return commitMsg{ + size: size, + name: commiter.FileName(), + } } } -func (m *model) decompressCmd(temp *os.File) tea.Cmd { +func (m *model) commitCmd() tea.Cmd { return func() tea.Msg { - // Reset file position for reading. - _, err := temp.Seek(0, 0) - if err != nil { - return ui.ErrorMsg(err) + if m.commiter == nil { + return tui.ErrorMsg(errors.New("nil commiter")) } - - // 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) + size, err := m.commiter.Commit() if err != nil { - return ui.ErrorMsg(err) + return tui.ErrorMsg(err) + } + defer func() { m.commiter = nil }() + return commitMsg{ + size: size, + name: m.commiter.FileName(), } - - return decompressionDoneMsg{fileNames, size} } } -// -------------------- HELPER METHODS ------------------------- +// ------------------------------------------------------ Helpers ------------------------------------------------------ func (m *model) newOverwritePrompt(fileName string) tea.Cmd { prompt := confirmation.New(fmt.Sprintf("Overwrite file '%s'?", fileName), confirmation.Yes) @@ -437,14 +450,14 @@ func (m *model) newOverwritePrompt(fileName string) tea.Cmd { func (m *model) resetSpinner() { m.spinner = spinner.New() - m.spinner.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(ui.ELEMENT_COLOR)) + m.spinner.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(tui.ELEMENT_COLOR)) if m.state == showEstablishing || m.state == showOverwritePrompt { - m.spinner.Spinner = ui.WaitingSpinner + m.spinner.Spinner = tui.WaitingSpinner } if m.state == showDecompressing { - m.spinner.Spinner = ui.CompressingSpinner + m.spinner.Spinner = tui.CompressingSpinner } if m.state == showReceivingProgress { - m.spinner.Spinner = ui.ReceivingSpinner + m.spinner.Spinner = tui.ReceivingSpinner } } diff --git a/ui/sender/sender.go b/cmd/portal/tui/sender/sender.go similarity index 69% rename from ui/sender/sender.go rename to cmd/portal/tui/sender/sender.go index 3728969..987b368 100644 --- a/ui/sender/sender.go +++ b/cmd/portal/tui/sender/sender.go @@ -8,15 +8,15 @@ import ( "strings" "time" - "github.com/SpatiumPortae/portal/internal/config" + "github.com/SpatiumPortae/portal/cmd/portal/config" + "github.com/SpatiumPortae/portal/cmd/portal/tui" + "github.com/SpatiumPortae/portal/cmd/portal/tui/filetable" + "github.com/SpatiumPortae/portal/cmd/portal/tui/transferprogress" "github.com/SpatiumPortae/portal/internal/conn" "github.com/SpatiumPortae/portal/internal/file" "github.com/SpatiumPortae/portal/internal/semver" "github.com/SpatiumPortae/portal/internal/sender" "github.com/SpatiumPortae/portal/protocol/transfer" - "github.com/SpatiumPortae/portal/ui" - "github.com/SpatiumPortae/portal/ui/filetable" - "github.com/SpatiumPortae/portal/ui/transferprogress" "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" @@ -29,13 +29,13 @@ import ( "golang.org/x/exp/slices" ) -// ------------------------------------------------------ Ui State ----------------------------------------------------- +// ------------------------------------------------------ tui State ----------------------------------------------------- -type uiState int +type tuiState int // flows from the top down. const ( - showPassword uiState = iota + showPassword tuiState = iota showSendingProgress showFinished ) @@ -70,7 +70,7 @@ func WithVersion(version semver.Version) Option { } type model struct { - state uiState // defaults to 0 (showPassword) + state tuiState // defaults to 0 (showPassword) transferType transfer.Type // defaults to 0 (Unknown) readyToSend bool ctx context.Context @@ -91,7 +91,7 @@ type model struct { transferProgress transferprogress.Model fileTable filetable.Model help help.Model - keys ui.KeyMap + keys tui.KeyMap copyMessageTimer timer.Model } @@ -104,8 +104,8 @@ func New(filenames []string, addr string, opts ...Option) *tea.Program { rendezvousAddr: addr, msgs: make(chan interface{}, 10), help: help.New(), - keys: ui.Keys, - copyMessageTimer: timer.NewWithInterval(ui.TEMP_UI_MESSAGE_DURATION, 100*time.Millisecond), + keys: tui.Keys, + copyMessageTimer: timer.NewWithInterval(tui.TEMP_UI_MESSAGE_DURATION, 100*time.Millisecond), ctx: context.Background(), } m.keys.FileListUp.SetEnabled(true) @@ -120,7 +120,7 @@ func New(filenames []string, addr string, opts ...Option) *tea.Program { func (m model) Init() tea.Cmd { var versionCmd tea.Cmd if m.version != nil { - versionCmd = ui.VersionCmd(m.ctx, m.rendezvousAddr) + versionCmd = tui.VersionCmd(m.ctx, m.rendezvousAddr) } return tea.Sequence(versionCmd, tea.Batch(m.spinner.Tick, readFilesCmd(m.fileNames), connectCmd(m.ctx, m.rendezvousAddr))) } @@ -130,31 +130,31 @@ func (m model) Init() tea.Cmd { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case ui.VersionMsg: + case tui.VersionMsg: var message string switch m.version.Compare(msg.ServerVersion) { case semver.CompareNewMajor, semver.CompareOldMajor: - //lint:ignore ST1005 error string displayed in UI - return m, ui.ErrorCmd(fmt.Errorf("Portal version (%s) incompatible with server version (%s)", m.version, msg.ServerVersion)) + //lint:ignore ST1005 error string displayed in tui + return m, tui.ErrorCmd(fmt.Errorf("Portal version (%s) incompatible with server version (%s)", m.version, msg.ServerVersion)) case semver.CompareNewMinor, semver.CompareNewPatch: - message = ui.WarningText(fmt.Sprintf("Portal version (%s) newer than server version (%s)", m.version, msg.ServerVersion)) + message = tui.WarningText(fmt.Sprintf("Portal version (%s) newer than server version (%s)", m.version, msg.ServerVersion)) case semver.CompareOldMinor, semver.CompareOldPatch: - message = ui.WarningText(fmt.Sprintf("Server version (%s) newer than Portal version (%s)", msg.ServerVersion, m.version)) + message = tui.WarningText(fmt.Sprintf("Server version (%s) newer than Portal version (%s)", msg.ServerVersion, m.version)) case semver.CompareEqual: - message = ui.SuccessText(fmt.Sprintf("Portal version (%s) compatible with server version (%s)", m.version, msg.ServerVersion)) + message = tui.SuccessText(fmt.Sprintf("Portal version (%s) compatible with server version (%s)", m.version, msg.ServerVersion)) } - return m, ui.TaskCmd(message, nil) + return m, tui.TaskCmd(message, nil) case fileReadMsg: m.uncompressedSize = msg.size - message := fmt.Sprintf("Read %d objects (%s)", len(m.fileNames), ui.ByteCountSI(msg.size)) + message := fmt.Sprintf("Read %d objects (%s)", len(m.fileNames), tui.ByteCountSI(msg.size)) if len(m.fileNames) == 1 { - message = fmt.Sprintf("Read %d object (%s)", len(m.fileNames), ui.ByteCountSI(msg.size)) + message = fmt.Sprintf("Read %d object (%s)", len(m.fileNames), tui.ByteCountSI(msg.size)) } - return m, ui.TaskCmd(message, compressFilesCmd(msg.files)) + return m, tui.TaskCmd(message, compressFilesCmd(msg.files)) case compressedMsg: m.payload = msg.payload @@ -162,23 +162,23 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.transferProgress.PayloadSize = msg.size m.readyToSend = true m.resetSpinner() - message := fmt.Sprintf("Compressed objects (%s)", ui.ByteCountSI(msg.size)) + message := fmt.Sprintf("Compressed objects (%s)", tui.ByteCountSI(msg.size)) if len(m.fileNames) == 1 { - message = fmt.Sprintf("Compressed object (%s)", ui.ByteCountSI(msg.size)) + message = fmt.Sprintf("Compressed object (%s)", tui.ByteCountSI(msg.size)) } - return m, ui.TaskCmd(message, m.spinner.Tick) + return m, tui.TaskCmd(message, m.spinner.Tick) case connectMsg: m.keys.CopyPassword.SetEnabled(true) m.password = msg.password connectMessage := fmt.Sprintf("Connected to Portal server (%s)", m.rendezvousAddr) - return m, ui.TaskCmd(connectMessage, secureCmd(m.ctx, msg.conn, msg.password)) + return m, tui.TaskCmd(connectMessage, secureCmd(m.ctx, msg.conn, msg.password)) case timer.TickMsg: var cmd tea.Cmd m.copyMessageTimer, cmd = m.copyMessageTimer.Update(msg) if m.copyMessageTimer.Running() { - m.keys.CopyPassword.SetHelp(m.keys.CopyPassword.Help().Key, ui.CopyKeyActiveHelpText) + m.keys.CopyPassword.SetHelp(m.keys.CopyPassword.Help().Key, tui.CopyKeyActiveHelpText) } return m, cmd @@ -186,10 +186,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd m.state = showPassword m.copyMessageTimer, cmd = m.copyMessageTimer.Update(msg) - m.keys.CopyPassword.SetHelp(m.keys.CopyPassword.Help().Key, ui.CopyKeyHelpText) + m.keys.CopyPassword.SetHelp(m.keys.CopyPassword.Help().Key, tui.CopyKeyHelpText) return m, cmd - case ui.TransferTypeMsg: + case tui.TransferTypeMsg: m.transferType = msg.Type var message string switch m.transferType { @@ -198,9 +198,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case transfer.Relay: message = "Using relayed connection to receiver" } - return m, ui.TaskCmd(message, listenTransferCmd(m.msgs)) + return m, tui.TaskCmd(message, listenTransferCmd(m.msgs)) - case ui.SecureMsg: + case tui.SecureMsg: // In the case we are not ready to send yet we pass on the same message. if !m.readyToSend { return m, func() tea.Msg { @@ -212,16 +212,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { transferCmd(m.ctx, msg.Conn, m.payload, m.payloadSize, m.msgs)) return m, cmd - case ui.TransferStateMessage: + case tui.TransferStateMessage: var message string switch msg.State { case transfer.ReceiverRequestPayload: m.keys.CopyPassword.SetEnabled(false) message = "Established encrypted connection to receiver" } - return m, ui.TaskCmd(message, listenTransferCmd(m.msgs)) + return m, tui.TaskCmd(message, listenTransferCmd(m.msgs)) - case ui.ProgressMsg: + case tui.ProgressMsg: cmds := []tea.Cmd{listenTransferCmd(m.msgs)} if m.state != showSendingProgress { m.state = showSendingProgress @@ -238,14 +238,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state = showFinished 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), + tui.ByteCountSI(m.transferProgress.TransferSpeedEstimateBps), ) m.fileTable = m.fileTable.Finalize().(filetable.Model) - return m, ui.TaskCmd(message, ui.QuitCmd()) + return m, tui.TaskCmd(message, tui.QuitCmd()) - case ui.ErrorMsg: - return m, ui.ErrorCmd(errors.New(msg.Error())) + case tui.ErrorMsg: + return m, tui.ErrorCmd(errors.New(msg.Error())) case tea.KeyMsg: switch { @@ -254,9 +254,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.CopyPassword): err := clipboard.WriteAll(m.copyReceiverCommand()) if err != nil { - return m, ui.ErrorCmd(errors.New("Failed to copy password to clipboard")) + return m, tui.ErrorCmd(errors.New("Failed to copy password to clipboard")) } else { - m.copyMessageTimer.Timeout = ui.TEMP_UI_MESSAGE_DURATION + m.copyMessageTimer.Timeout = tui.TEMP_UI_MESSAGE_DURATION cmd := m.copyMessageTimer.Init() return m, cmd } @@ -286,7 +286,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m model) View() string { // Setup strings to use in view. - uncompressed := ui.BoldText(ui.ByteCountSI(m.uncompressedSize)) + uncompressed := tui.BoldText(tui.ByteCountSI(m.uncompressedSize)) readiness := fmt.Sprintf("%s Compressing objects (%s), preparing to send", m.spinner.View(), uncompressed) if m.readyToSend { readiness = fmt.Sprintf("%s Awaiting receiver, ready to send", m.spinner.View()) @@ -296,47 +296,47 @@ func (m model) View() string { } slices.Sort(m.fileNames) - builder := strings.Builder{} - builder.WriteString(fmt.Sprintf("%s %d object", readiness, len(m.fileNames))) + btuilder := strings.Builder{} + btuilder.WriteString(fmt.Sprintf("%s %d object", readiness, len(m.fileNames))) if len(m.fileNames) > 1 { - builder.WriteRune('s') + btuilder.WriteRune('s') } if m.payloadSize != 0 { - compressed := ui.BoldText(ui.ByteCountSI(m.payloadSize)) - builder.WriteString(fmt.Sprintf(" (%s)", compressed)) + compressed := tui.BoldText(tui.ByteCountSI(m.payloadSize)) + btuilder.WriteString(fmt.Sprintf(" (%s)", compressed)) } switch m.transferType { case transfer.Direct: - builder.WriteString(" using direct transfer") + btuilder.WriteString(" using direct transfer") case transfer.Relay: - builder.WriteString(" using relayed transfer") + btuilder.WriteString(" using relayed transfer") case transfer.Unknown: } - statusText := builder.String() + statusText := btuilder.String() switch m.state { case showPassword: - 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(m.copyReceiverCommand()) + "\n\n" + + return tui.PadText + tui.LogSeparator(m.width) + + tui.PadText + tui.InfoStyle(statusText) + "\n\n" + + tui.PadText + tui.InfoStyle("On the receiving end, run:") + "\n" + + tui.PadText + tui.InfoStyle(m.copyReceiverCommand()) + "\n\n" + m.fileTable.View() + - ui.PadText + m.help.View(m.keys) + "\n\n" + tui.PadText + m.help.View(m.keys) + "\n\n" case showSendingProgress: - return ui.PadText + ui.LogSeparator(m.width) + - ui.PadText + ui.InfoStyle(statusText) + "\n\n" + - ui.PadText + m.transferProgress.View() + "\n\n" + + return tui.PadText + tui.LogSeparator(m.width) + + tui.PadText + tui.InfoStyle(statusText) + "\n\n" + + tui.PadText + m.transferProgress.View() + "\n\n" + m.fileTable.View() + - ui.PadText + m.help.View(m.keys) + "\n\n" + tui.PadText + m.help.View(m.keys) + "\n\n" case showFinished: - finishedText := fmt.Sprintf("Sent %d object(s) (%s compressed)", len(m.fileNames), ui.ByteCountSI(m.payloadSize)) - return ui.PadText + ui.LogSeparator(m.width) + - ui.PadText + ui.InfoStyle(finishedText) + "\n\n" + - ui.PadText + m.transferProgress.View() + "\n\n" + + finishedText := fmt.Sprintf("Sent %d object(s) (%s compressed)", len(m.fileNames), tui.ByteCountSI(m.payloadSize)) + return tui.PadText + tui.LogSeparator(m.width) + + tui.PadText + tui.InfoStyle(finishedText) + "\n\n" + + tui.PadText + m.transferProgress.View() + "\n\n" + m.fileTable.View() default: @@ -351,7 +351,7 @@ func connectCmd(ctx context.Context, addr string) tea.Cmd { return func() tea.Msg { rc, password, err := sender.ConnectRendezvous(ctx, addr) if err != nil { - return ui.ErrorMsg(err) + return tui.ErrorMsg(err) } return connectMsg{password: password, conn: rc} } @@ -362,19 +362,19 @@ func secureCmd(ctx context.Context, rc conn.Rendezvous, password string) tea.Cmd return func() tea.Msg { tc, err := sender.SecureConnection(ctx, rc, password) if err != nil { - return ui.ErrorMsg(err) + return tui.ErrorMsg(err) } - return ui.SecureMsg{Conn: tc} + return tui.SecureMsg{Conn: tc} } } // transferCmd command that does the transfer sequence. -// The msgs channel is used to provide intermediate messages to the ui. +// The msgs channel is used to provide intermediate messages to the tui. func transferCmd(ctx context.Context, tc conn.Transfer, payload io.Reader, payloadSize int64, msgs ...chan interface{}) tea.Cmd { return func() tea.Msg { err := sender.Transfer(ctx, tc, payload, payloadSize, msgs...) if err != nil { - return ui.ErrorMsg(err) + return tui.ErrorMsg(err) } return transferDoneMsg{} } @@ -385,14 +385,14 @@ func readFilesCmd(paths []string) tea.Cmd { return func() tea.Msg { files, err := file.ReadFiles(paths) if err != nil { - return ui.ErrorMsg(err) + return tui.ErrorMsg(err) } var totalSize int64 for _, f := range files { size, err := file.FileSize(f.Name()) if err != nil { - return ui.ErrorMsg(err) + return tui.ErrorMsg(err) } totalSize += size } @@ -412,7 +412,7 @@ func compressFilesCmd(files []*os.File) tea.Cmd { }() tar, size, err := file.PackFiles(files) if err != nil { - return ui.ErrorMsg(err) + return tui.ErrorMsg(err) } return compressedMsg{payload: tar, size: size} } @@ -425,11 +425,11 @@ func listenTransferCmd(msgs chan interface{}) tea.Cmd { msg := <-msgs switch v := msg.(type) { case transfer.Type: - return ui.TransferTypeMsg{Type: v} + return tui.TransferTypeMsg{Type: v} case transfer.MsgType: - return ui.TransferStateMessage{State: v} + return tui.TransferStateMessage{State: v} case int: - return ui.ProgressMsg(v) + return tui.ProgressMsg(v) default: return nil } @@ -440,29 +440,29 @@ func listenTransferCmd(msgs chan interface{}) tea.Cmd { func (m *model) resetSpinner() { m.spinner = spinner.New() - m.spinner.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(ui.ELEMENT_COLOR)) + m.spinner.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(tui.ELEMENT_COLOR)) if m.readyToSend { - m.spinner.Spinner = ui.WaitingSpinner + m.spinner.Spinner = tui.WaitingSpinner } else { - m.spinner.Spinner = ui.CompressingSpinner + m.spinner.Spinner = tui.CompressingSpinner } if m.state == showSendingProgress { - m.spinner.Spinner = ui.TransferSpinner + m.spinner.Spinner = tui.TransferSpinner } } func (m *model) copyReceiverCommand() string { - var builder strings.Builder - builder.WriteString("portal receive ") - builder.WriteString(m.password) + var btuilder strings.Builder + btuilder.WriteString("portal receive ") + btuilder.WriteString(m.password) relayAddrKey := "relay" if !config.IsDefault(relayAddrKey) { - builder.WriteRune(' ') - builder.WriteString(fmt.Sprintf("--%s", relayAddrKey)) - builder.WriteRune(' ') - builder.WriteString(viper.GetString(relayAddrKey)) + btuilder.WriteRune(' ') + btuilder.WriteString(fmt.Sprintf("--%s", relayAddrKey)) + btuilder.WriteRune(' ') + btuilder.WriteString(viper.GetString(relayAddrKey)) } - return builder.String() + return btuilder.String() } diff --git a/ui/transferprogress/transferprogress.go b/cmd/portal/tui/transferprogress/transferprogress.go similarity index 78% rename from ui/transferprogress/transferprogress.go rename to cmd/portal/tui/transferprogress/transferprogress.go index 75994e9..59ce382 100644 --- a/ui/transferprogress/transferprogress.go +++ b/cmd/portal/tui/transferprogress/transferprogress.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "github.com/SpatiumPortae/portal/ui" + "github.com/SpatiumPortae/portal/cmd/portal/tui" "github.com/charmbracelet/bubbles/progress" tea "github.com/charmbracelet/bubbletea" "github.com/pkg/errors" @@ -33,7 +33,7 @@ func (m *Model) StartTransfer() { func New(opts ...Option) Model { m := Model{ - progressBar: ui.NewProgressBar(), + progressBar: tui.NewProgressBar(), } for _, opt := range opts { @@ -49,9 +49,9 @@ func (Model) Init() tea.Cmd { func (m Model) View() string { bytesProgress := strings.Builder{} bytesProgress.WriteRune('(') - bytesProgress.WriteString(fmt.Sprintf("%s/%s", ui.ByteCountSI(m.bytesTransferred), ui.ByteCountSI(m.PayloadSize))) + bytesProgress.WriteString(fmt.Sprintf("%s/%s", tui.ByteCountSI(m.bytesTransferred), tui.ByteCountSI(m.PayloadSize))) if m.TransferSpeedEstimateBps > 0 { - bytesProgress.WriteString(fmt.Sprintf(", %s/s", ui.ByteCountSI(m.TransferSpeedEstimateBps))) + bytesProgress.WriteString(fmt.Sprintf(", %s/s", tui.ByteCountSI(m.TransferSpeedEstimateBps))) } bytesProgress.WriteRune(')') @@ -63,27 +63,27 @@ func (m Model) View() string { progressBar := m.progressBar.ViewAs(m.progress) return bytesProgress.String() + "\t\t" + eta + "\n\n" + - ui.PadText + progressBar + tui.PadText + progressBar } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: - m.Width = msg.Width - 2*ui.MARGIN - 4 - if m.Width > ui.MAX_WIDTH { - m.Width = ui.MAX_WIDTH + m.Width = msg.Width - 2*tui.MARGIN - 4 + if m.Width > tui.MAX_WIDTH { + m.Width = tui.MAX_WIDTH } m.progressBar.Width = m.Width return m, nil - case ui.ProgressMsg: + case tui.ProgressMsg: secondsSpent := time.Since(m.TransferStartTime).Seconds() if m.bytesTransferred > 0 { bytesRemaining := m.PayloadSize - m.bytesTransferred linearRemainingSeconds := float64(bytesRemaining) * secondsSpent / float64(m.bytesTransferred) if remainingDuration, err := time.ParseDuration(fmt.Sprintf("%fs", linearRemainingSeconds)); err != nil { - return m, ui.ErrorCmd(errors.Wrap(err, "failed to parse duration of transfer ETA")) + return m, tui.ErrorCmd(errors.Wrap(err, "failed to parse duration of transfer ETA")) } else { m.estimatedRemainingDuration = remainingDuration } diff --git a/ui/ui.go b/cmd/portal/tui/tui.go similarity index 99% rename from ui/ui.go rename to cmd/portal/tui/tui.go index 9c6adf9..bd742d2 100644 --- a/ui/ui.go +++ b/cmd/portal/tui/tui.go @@ -1,4 +1,4 @@ -package ui +package tui import ( "context" diff --git a/cmd/portal/validate.go b/cmd/portal/validate.go deleted file mode 100644 index 2259bb2..0000000 --- a/cmd/portal/validate.go +++ /dev/null @@ -1,46 +0,0 @@ -package main - -import ( - "errors" - "net" - "strconv" - - "github.com/go-playground/validator/v10" -) - -var validate = validator.New() -var ErrInvalidAddress = errors.New("invalid address provided") - -// validateAddress validates a hostname or IP, optionally with a port. -func validateAddress(addr string) error { - - // IPv4 and IPv6 address validation. - err := validate.Var(addr, "ip") - if err == nil { - return nil - } - - // IPv4 or IPv6 or domain or localhost. - err = validate.Var(addr, "hostname") - if err == nil { - return nil - } - - // IPv4 or domain or localhost and a port. Or just a shortand port (:1234). - err = validate.Var(addr, "hostname_port") - if err == nil { - return nil - } - - // Also validate IPv6 host + port combination. The hostname_port validator does not validate this. - _, port, hostPortErr := net.SplitHostPort(addr) - // Additionally, validate the port range. - if p, err := strconv.Atoi(port); err != nil || p < 0 || p > 65535 { - return ErrInvalidAddress - } - if hostPortErr == nil { - return nil - } - - return ErrInvalidAddress -} diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index cc01911..1901c02 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -7,7 +7,7 @@ import ( "context" "syscall/js" - "github.com/SpatiumPortae/portal/portal" + "github.com/SpatiumPortae/portal/internal/portal" ) // JS constructors diff --git a/go.mod b/go.mod index c41a03e..e8cec0f 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/SpatiumPortae/portal -go 1.18 +go 1.20 require ( github.com/alecthomas/chroma v0.10.0 @@ -20,7 +20,6 @@ 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 ) @@ -53,14 +52,11 @@ require ( 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 - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.11.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/google/uuid v1.3.0 // indirect - github.com/leodido/go-urn v1.2.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/moby/sys/mount v0.2.0 // indirect @@ -75,14 +71,17 @@ require ( github.com/pelletier/go-toml/v2 v2.0.6 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.3 // indirect + github.com/rogpeppe/go-internal v1.8.0 // indirect github.com/sirupsen/logrus v1.8.1 // indirect 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 google.golang.org/protobuf v1.28.1 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) require ( diff --git a/go.sum b/go.sum index caf54f3..2e4b282 100644 --- a/go.sum +++ b/go.sum @@ -340,15 +340,10 @@ github.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8 github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY= github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= github.com/go-playground/validator/v10 v10.11.2 h1:q3SHpufmypg+erIExEKUmsgmhDTyhcJ38oeKGACXohU= github.com/go-playground/validator/v10 v10.11.2/go.mod h1:NieE624vt4SCTJtD87arVLvdmjPAeV8BQlHtMnw9D7s= @@ -526,10 +521,8 @@ github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= -github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= @@ -653,6 +646,7 @@ github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrap github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU= github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -697,8 +691,8 @@ github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4= @@ -1232,8 +1226,9 @@ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLks gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20141024133853-64131543e789/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 3be111d..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,52 +0,0 @@ -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 d800e12..a3dc84d 100644 --- a/internal/file/file.go +++ b/internal/file/file.go @@ -3,6 +3,7 @@ package file import ( "archive/tar" "bufio" + "errors" "fmt" "io" "os" @@ -15,7 +16,7 @@ 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) +// ----------------------------------------------------- Pack Files ---------------------------------------------------- func ReadFiles(fileNames []string) ([]*os.File, error) { var files []*os.File @@ -62,82 +63,135 @@ func PackFiles(files []*os.File) (*os.File, int64, error) { return tempFile, fileInfo.Size(), nil } -// UnpackFiles gzip-decompresses and un-tars files into the current working directory -// and returns the names and decompressed size of the created files -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) +// ---------------------------------------------------- Unpack Files --------------------------------------------------- + +var ErrUnpackNoHeader = errors.New("no header in tar archive") +var ErrUnpackFileExists = errors.New("file exists") +var ErrUninitialized = errors.New("unpacker is uninitialized") + +// Unpacker defines an encapsulated unit for unpacking a compressed +// tar archive +type Unpacker struct { + prompt bool // prompt defines whether we should prompt the user to overwrite files + cwd string + + gr *pgzip.Reader + tr *tar.Reader + r io.ReadCloser +} + +func NewUnpacker(prompt bool, r io.ReadCloser) (*Unpacker, error) { + gr, err := pgzip.NewReader(r) if err != nil { - return nil, 0, err + return nil, err + } + cwd, err := os.Getwd() + if err != nil { + return nil, err } - defer gr.Close() tr := tar.NewReader(gr) - var createdFiles []string - var decompressedSize int64 - for { - header, err := tr.Next() + return &Unpacker{ + prompt: prompt, + cwd: cwd, + gr: gr, + tr: tr, + r: r, + }, nil +} - if err == io.EOF { - break - } - if err != nil { - return nil, 0, err - } - if header == nil { - continue +// Close closes all underlying readers of the unpacker. +func (u *Unpacker) Close() error { + if u.gr != nil { + if err := u.gr.Close(); err != nil { + return err } - - cwd, err := os.Getwd() - if err != nil { - return nil, 0, err + } + if u.r != nil { + if err := u.r.Close(); err != nil { + return err } + } + return nil +} - fileTarget := filepath.Join(cwd, header.Name) - - switch header.Typeflag { +// Unpack will decompress and unpack the archive. Resolves a Committer +// which can be used to write file to disk. If the unpacker is configured to prompt +// it will return a ErrUnpackFileExists along with the committer. Returns a io.EOF +// once the archive has been fully consumed. +func (u *Unpacker) Unpack() (Committer, error) { + if u.tr == nil { + return nil, ErrUninitialized + } + header, err := u.tr.Next() + switch { + case err != nil: + return nil, err + case header == nil: + return nil, ErrUnpackNoHeader + } + path := filepath.Join(u.cwd, header.Name) + commiter := committer{ + cwd: u.cwd, + name: header.Name, + tr: u.tr, + header: header, + } - case tar.TypeDir: - if _, err := os.Stat(fileTarget); err != nil { - if err := os.MkdirAll(fileTarget, 0755); err != nil { - return nil, 0, err - } - } + if u.prompt && header.Typeflag == tar.TypeReg && fileExists(path) { + return &commiter, ErrUnpackFileExists + } + return &commiter, nil +} - case tar.TypeReg: - if fileExists(fileTarget) { - shouldOverwrite, err := decideOverwrite(fileTarget) - if err != nil { - return nil, 0, err - } - if !shouldOverwrite { - continue - } - } +// Committer defines a unit that can commit a file to disk +type Committer interface { + FileName() string + Commit() (int64, error) +} - f, err := os.OpenFile(fileTarget, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode)) - if err != nil { - return nil, 0, err - } +type committer struct { + cwd string + name string + tr *tar.Reader + header *tar.Header +} - if _, err := io.Copy(f, tr); err != nil { - return nil, 0, err - } +func (c *committer) FileName() string { + return c.name +} - fileInfo, err := f.Stat() - if err != nil { - return nil, 0, err +func (c *committer) Commit() (int64, error) { + path := filepath.Join(c.cwd, c.name) + switch c.header.Typeflag { + case tar.TypeDir: + if _, err := os.Stat(path); err != nil { + if err := os.MkdirAll(path, 0755); err != nil { + return 0, err } - - decompressedSize += fileInfo.Size() - createdFiles = append(createdFiles, header.Name) - f.Close() } + return 0, nil + case tar.TypeReg: + f, err := os.Create(path) + if err != nil { + return 0, err + } + defer f.Close() + if _, err := io.Copy(f, c.tr); err != nil { + return 0, err + } + info, err := f.Stat() + if err != nil { + return 0, err + } + return info.Size(), nil + default: + return 0, errors.New("unsupported file type") } - - return createdFiles, decompressedSize, nil } +// ----------------------------------------------------- Utilities ----------------------------------------------------- + // Traverses a file or directory recursively for total size in bytes. func FileSize(filePath string) (int64, error) { var size int64 @@ -154,6 +208,26 @@ func FileSize(filePath string) (int64, error) { return size, nil } +// optimistically remove files created by portal with the specified prefix +func RemoveTemporaryFiles(prefix string) { + tempFiles, err := os.ReadDir(os.TempDir()) + if err != nil { + return + } + for _, tempFile := range tempFiles { + fileInfo, err := tempFile.Info() + if err != nil { + continue + } + fileName := fileInfo.Name() + if strings.HasPrefix(fileName, prefix) { + os.Remove(filepath.Join(os.TempDir(), fileName)) + } + } +} + +// ------------------------------------------------------- Helper ------------------------------------------------------ + // addToTarArchive adds a file/folder to a tar archive. // Handles symlinks by replacing them with the files that they point to. func addToTarArchive(tw *tar.Writer, file *os.File) error { @@ -216,21 +290,3 @@ 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()) - if err != nil { - return - } - for _, tempFile := range tempFiles { - fileInfo, err := tempFile.Info() - if err != nil { - continue - } - fileName := fileInfo.Name() - if strings.HasPrefix(fileName, prefix) { - os.Remove(filepath.Join(os.TempDir(), fileName)) - } - } -} diff --git a/internal/password/password.go b/internal/password/password.go index 175d6a2..b9c5321 100644 --- a/internal/password/password.go +++ b/internal/password/password.go @@ -1,10 +1,12 @@ package password import ( + crypto_rand "crypto/rand" "crypto/sha256" + "encoding/binary" "encoding/hex" "fmt" - "math/rand" + math_rand "math/rand" "regexp" "github.com/SpatiumPortae/portal/data" @@ -14,18 +16,23 @@ import ( const Length = 3 // GeneratePassword generates a random password prefixed with the supplied id. -func Generate(id int) string { +func Generate(id int) (string, error) { var words []string hitlistSize := len(data.SpaceWordList) + rng, err := random() + if err != nil { + return "", fmt.Errorf("creating rng: %w", err) + } + // generate three unique words for len(words) != Length { - candidateWord := data.SpaceWordList[rand.Intn(hitlistSize)] + candidateWord := data.SpaceWordList[rng.Intn(hitlistSize)] if !slices.Contains(words, candidateWord) { words = append(words, candidateWord) } } - return formatPassword(id, words) + return formatPassword(id, words), nil } func IsValid(passStr string) bool { @@ -42,3 +49,12 @@ func Hashed(password string) string { func formatPassword(prefixIndex int, words []string) string { return fmt.Sprintf("%d-%s-%s-%s", prefixIndex, words[0], words[1], words[2]) } + +func random() (*math_rand.Rand, error) { + var b [8]byte + _, err := crypto_rand.Read(b[:]) + if err != nil { + return nil, err + } + return math_rand.New(math_rand.NewSource(int64(binary.LittleEndian.Uint64(b[:])))), nil +} diff --git a/portal/config.go b/internal/portal/config.go similarity index 95% rename from portal/config.go rename to internal/portal/config.go index 35c9102..9114b90 100644 --- a/portal/config.go +++ b/internal/portal/config.go @@ -9,7 +9,7 @@ import ( // defaultConfig specifies the default config for the portal module. var defaultConfig = Config{ - RendezvousAddr: "167.71.65.96:80", + RendezvousAddr: "portal.spatiumportae.com", } // Config specifes a config for the portal module. diff --git a/portal/portal.go b/internal/portal/portal.go similarity index 96% rename from portal/portal.go rename to internal/portal/portal.go index ad260bc..d4d606e 100644 --- a/portal/portal.go +++ b/internal/portal/portal.go @@ -15,9 +15,6 @@ import ( // can be listend to. The provided config will be merged with the default config. func Send(ctx context.Context, payload io.Reader, payloadSize int64, config *Config) (string, error, chan error) { merged := MergeConfig(defaultConfig, config) - if err := sender.Init(); err != nil { - return "", err, nil - } errC := make(chan error, 1) // buffer channel as to not block send. rc, password, err := sender.ConnectRendezvous(ctx, merged.RendezvousAddr) if err != nil { diff --git a/portal/portal_test.go b/internal/portal/portal_test.go similarity index 97% rename from portal/portal_test.go rename to internal/portal/portal_test.go index 65b0c43..7447f1a 100644 --- a/portal/portal_test.go +++ b/internal/portal/portal_test.go @@ -8,7 +8,7 @@ import ( "net/http" "testing" - "github.com/SpatiumPortae/portal/portal" + "github.com/SpatiumPortae/portal/internal/portal" "github.com/docker/go-connections/nat" "github.com/stretchr/testify/assert" "github.com/testcontainers/testcontainers-go" diff --git a/internal/sender/sender.go b/internal/sender/sender.go index b762752..7e9bc0c 100644 --- a/internal/sender/sender.go +++ b/internal/sender/sender.go @@ -3,12 +3,9 @@ package sender import ( "bufio" "context" - "crypto/rand" crypto_rand "crypto/rand" - "encoding/binary" "fmt" "io" - math_rand "math/rand" "github.com/SpatiumPortae/portal/internal/conn" "github.com/SpatiumPortae/portal/internal/password" @@ -21,10 +18,6 @@ import ( const MAX_CHUNK_BYTES = 1e6 const MAX_SEND_CHUNKS = 2e8 -func Init() error { - return randomSeed() -} - // ConnectRendezvous creates a connection with the rendezvous server and acquires a password associated with the connection func ConnectRendezvous(ctx context.Context, addr string) (conn.Rendezvous, string, error) { ws, _, err := websocket.Dial(context.Background(), fmt.Sprintf("ws://%s/establish-sender", addr), nil) @@ -38,7 +31,10 @@ func ConnectRendezvous(ctx context.Context, addr string) (conn.Rendezvous, strin if err != nil { return conn.Rendezvous{}, "", err } - pass := password.Generate(msg.Payload.ID) + pass, err := password.Generate(msg.Payload.ID) + if err != nil { + return conn.Rendezvous{}, "", err + } if err := rc.WriteMsg(ctx, rendezvous.Msg{ Type: rendezvous.SenderToRendezvousEstablish, @@ -86,7 +82,7 @@ func SecureConnection(ctx context.Context, rc conn.Rendezvous, password string) // create salt and session key. salt := make([]byte, 8) - if _, err := rand.Read(salt); err != nil { + if _, err := crypto_rand.Read(salt); err != nil { return conn.Transfer{}, err } @@ -185,13 +181,3 @@ func chunkSize(payloadSize int64) int64 { } return chunkSize } - -func randomSeed() error { - var b [8]byte - _, err := crypto_rand.Read(b[:]) - if err != nil { - return err - } - math_rand.Seed(int64(binary.LittleEndian.Uint64(b[:]))) - return nil -}