~kota/pcf

8c559cfad10db521fd0c808887cd715118552e65 — Dakota Walsh 1 year, 5 months ago cf52568 master
add initial sftp support
9 files changed, 476 insertions(+), 143 deletions(-)

M Makefile
M README.md
A config.go
M config.mk
A config_test.go
M go.mod
M go.sum
M main.go
A request.go
M Makefile => Makefile +2 -2
@@ 1,5 1,5 @@
# pcf
# Copyright (C) 2019 Dakota Walsh
# pcf - A command line sha1/(S)FTP-based pastebin client.
# Copyright (C) 2022 Dakota Walsh
# GPL3+ See LICENSE in this repo for details.
.POSIX:


M README.md => README.md +28 -30
@@ 1,30 1,34 @@
pcf
====

A simple command line sha1/FTP-based pastebin client. Reads file(s) from STDIN or
filename as argument, uploads file(s) to a server, and prints the url(s) to
# pcf
A command line sha1/(S)FTP-based pastebin client. Reads from STDIN or a list of
filenames as arguments, uploads files to a server, and prints the urls to
STDOUT.

The PCFSERVER environment variable is used to declare server information. An
example in your shellrc would be `export
PCFSERVER='https://paste.example.com:21/incoming'`. The port and path are optional
and will depend on how the pcf server you're using is configured.

Checkout [paste.nilsu.org](https://paste.nilsu.org) for a free public pcf
server. You can also create your own pcf server with `incron`, (anonymous) `ftpd`,
and a script to move and rename the file to it's `SHA1.extension`. Here's [the
script used on paste.nilsu.org](https://paste.nilsu.org/rename.py).
Server information is configured using a simple `toml` format in
`$XDG_CONFIG_HOME/pcf/config.toml` which is typically
`$HOME/.config/pcf/config.toml`. An example configuration is shown below with
the details for a free public pcf server
[paste.nilsu.org](https://paste.nilsu.org).

License
--------
## Config
```toml
ftp_url = "ftp://paste.nilsu.org:21/incoming"
sftp_anon_url = "sftp://paste.nilsu.org:22/incoming"
sftp_auth_url = "sftp://paste.nilsu.org:22/var/www/html/paste"
sftp_user = "kota"
sftp_pass = "cowscows"

GPL3+ see LICENSE in this repo for more details.
# Default mode. Options are "ftp", "sftp-anon", and "sftp-auth".
default_mode = "sftp-anon"

Build
------
# Prefix for the resulting file sha1 name.
output = "https://paste.nilsu.org/"
```

Build dependencies  
## License
GPL3+ see LICENSE in this repo for more details.

## Build
Build dependencies:
 * golang
 * make
 * sed


@@ 32,22 36,16 @@ Build dependencies

`make all`

Install
--------

Optionally configure `config.mk` to specify a different install location.  
## Install
Optionally configure `config.mk` to specify a different install location.\
Defaults to `/usr/local/`

`sudo make install`

Uninstall
----------

## Uninstall
`sudo make uninstall`

Resources
----------

## Resources
[Send patches](https://git-send-email.io) and questions to
[~kota/pcf@lists.sr.ht](https://lists.sr.ht/~kota/pcf).


A config.go => config.go +63 -0
@@ 0,0 1,63 @@
// pcf - A command line sha1/(S)FTP-based pastebin client.
// Copyright (C) 2022 Dakota Walsh
// GPL3+ See LICENSE in this repo for details.
package main

import (
	"fmt"
	"os"

	"github.com/BurntSushi/toml"
)

// Config a user's options for pcf.
type Config struct {
	// URL for anonymous FTP uploads. Anonymous FTP is an FTP server with an
	// account (defaulting to anonymous) which has no password.
	FtpURL string `toml:"ftp_url"`

	// URL for anonymous SFTP uploads. Anonymous SFTP is an SFTP server with an
	// account (defaulting to anonymous) which has no password.
	SftpAnonURL string `toml:"sftp_anon_url"`

	// URL for authenticated SFTP uploads.
	SftpAuthURL string `toml:"sftp_auth_url"`

	// Username for authenticated SFTP uploads.
	SftpUser string `toml:"sftp_user"`

	// Password for authenticated SFTP uploads.
	SftpPass string `toml:"sftp_pass"`

	// Options are "ftp", "sftp-anon", and "sftp-auth".
	// When using "sftp-auth": SftpUser and SftpPass are used for
	// authentication.
	//
	// If unset "ftp" will be used as the default mode.
	DefaultMode string `toml:"default_mode"`

	// Output is a prefix, typically a url, to be printed before the new
	// filename.
	Output string `toml:"output"`
}

// LoadConfig reads a config from a filename and returns a Config.
func LoadConfig(filename string) (*Config, error) {
	c := &Config{}
	f, err := os.Open(filename)
	if err != nil {
		return c, fmt.Errorf("failed opening config file: %v", err)
	}

	t := toml.NewDecoder(f)
	_, err = t.Decode(c) // TOML metadata is ignored.
	if err != nil {
		return c, fmt.Errorf("failed parsing config file: %v", err)
	}

	if err := f.Close(); err != nil {
		return c, fmt.Errorf("failed closing config file: %v", err)
	}

	return c, err
}

M config.mk => config.mk +3 -3
@@ 1,7 1,7 @@
# pcf
# Copyright (C) 2019 Dakota Walsh
# pcf - A command line sha1/FTP-based pastebin client.
# Copyright (C) 2022 Dakota Walsh
# GPL3+ See LICENSE in this repo for details.
VERSION = 2.0.4
VERSION = 3.0.0

# Customize below to fit your system


A config_test.go => config_test.go +57 -0
@@ 0,0 1,57 @@
// pcf - A command line sha1/(S)FTP-based pastebin client.
// Copyright (C) 2022 Dakota Walsh
// GPL3+ See LICENSE in this repo for details.
package main

import (
	"os"
	"path/filepath"
	"testing"
)

var (
	testConfigData = []byte(`ftp_url = "ftp://paste.nilsu.org:21/incoming"
sftp_anon_url = "sftp://paste.nilsu.org:22/incoming"
sftp_auth_url = "sftp://paste.nilsu.org:22/var/www/html/paste"
sftp_user = "kota"
sftp_pass = "cowscows"
# Default mode. Options are "ftp" "sftp-anon" and "sftp-auth".
default_mode = "sftp-anon"
output = "https://paste.nilsu.org/"`)
)

func TestLoadConfig(t *testing.T) {
	configPath := filepath.Join(t.TempDir(), "config.toml")
	os.WriteFile(
		configPath,
		testConfigData,
		0666,
	)

	c, err := LoadConfig(configPath)
	if err != nil {
		t.Fatalf("failed reading config at %s: %v\n", configPath, err)
	}

	if c.FtpURL != "ftp://paste.nilsu.org:21/incoming" {
		t.Fatal("incorrect ftp_url from testConfigFile")
	}
	if c.SftpAnonURL != "sftp://paste.nilsu.org:22/incoming" {
		t.Fatal("incorrect sftp_anon_url from testConfigFile")
	}
	if c.SftpAuthURL != "sftp://paste.nilsu.org:22/var/www/html/paste" {
		t.Fatal("incorrect sftp_auth_url from testConfigFile")
	}
	if c.SftpUser != "kota" {
		t.Fatal("incorrect sftp_user from testConfigFile")
	}
	if c.SftpPass != "cowscows" {
		t.Fatal("incorrect sftp_pass from testConfigFile")
	}
	if c.DefaultMode != "sftp-anon" {
		t.Fatal("incorrect default_mode from testConfigFile")
	}
	if c.Output != "https://paste.nilsu.org/" {
		t.Fatal("incorrect output from testConfigFile")
	}
}

M go.mod => go.mod +8 -2
@@ 1,5 1,11 @@
module git.sr.ht/~kota/pcf

go 1.12
go 1.13

require github.com/jlaffaye/ftp v0.0.0-20210307004419-5d4190119067
require (
	github.com/BurntSushi/toml v1.0.0
	github.com/adrg/xdg v0.4.0
	github.com/jlaffaye/ftp v0.0.0-20210307004419-5d4190119067
	github.com/pkg/sftp v1.13.4
	golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b
)

M go.sum => go.sum +21 -1
@@ 1,12 1,32 @@
github.com/BurntSushi/toml v1.0.0 h1:dtDWrepsVPfW9H/4y7dDgFc2MBUSeJhlaDtK13CxFlU=
github.com/BurntSushi/toml v1.0.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls=
github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jlaffaye/ftp v0.0.0-20210307004419-5d4190119067 h1:P2S26PMwXl8+ZGuOG3C69LG4be5vHafUayZm9VPw3tU=
github.com/jlaffaye/ftp v0.0.0-20210307004419-5d4190119067/go.mod h1:2lmrmq866uF2tnje75wQHzmPXhmSWUt7Gyx2vgK1RCU=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/pkg/sftp v1.13.4 h1:Lb0RYJCmgUcBgZosfoi9Y9sbl6+LJgOIgk/2Y4YjMFg=
github.com/pkg/sftp v1.13.4/go.mod h1:LzqnAvaD5TWeNBsZpfKxSYn1MbjWwOsCIAFFJbpIsK8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359 h1:2B5p2L5IfGiD7+b9BOoRMC6DgObAVZV+Fsp050NqXik=
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=

M main.go => main.go +118 -105
@@ 1,143 1,156 @@
// pcf - A powerful paste.cf command line client
// Copyright (C) 2019 Dakota Walsh
// pcf - A command line sha1/(S)FTP-based pastebin client.
// Copyright (C) 2022 Dakota Walsh
// GPL3+ See LICENSE in this repo for details.
package main

import (
	"bytes"
	"crypto/sha1"
	"flag"
	"fmt"
	"io"
	"net/url"
	"math/rand"
	"os"
	"path"
	"path/filepath"
	"time"
	"strings"

	"github.com/jlaffaye/ftp"
	"github.com/adrg/xdg"
)

func main() {
	addr := os.Getenv("PCFSERVER")
	if addr == "" {
		fmt.Println("pcf: you must set the PCFSERVER environment variable!")
	// Find and read config.
	configPath, err := xdg.ConfigFile("pcf/config.toml")
	if err != nil {
		fmt.Fprintf(os.Stderr, "failed reading config directory: %v\n", err)
		os.Exit(1)
	}

	// parse the ftpURL
	ftpURL, err := url.Parse(addr)
	config, err := LoadConfig(configPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "pcf: url configuration wrong: %v\n", err)
		fmt.Fprintf(os.Stderr, "failed loading config file: %v\n", err)
		os.Exit(1)
	}
	args := os.Args[1:]

	if len(args) == 0 {
		// use stdin data
		inBytes, err := ReadAll(os.Stdin)
	// Parse flags and override config.DefaultMode if needed.
	ftpFlag := flag.Bool("f", false, "use anonymous FTP")
	sftpFlag := flag.Bool("s", false, "use anonymous SFTP")
	authFlag := flag.Bool("a", false, "use authenticated SFTP")

	flag.Parse()

	mode := config.DefaultMode
	switch {
	case *authFlag:
		mode = "sftp-auth"
	case *sftpFlag:
		mode = "sftp-anon"
	case *ftpFlag:
		mode = "ftp"
	}

	// Select the correct upload URL.
	var url string
	switch mode {
	case "sftp-auth":
		url = config.SftpAuthURL
	case "sftp-anon":
		url = config.SftpAnonURL
	default:
		url = config.FtpURL
	}

	// Create requests and upload each file.
	filePaths := flag.Args()
	UploadFiles(
		mode,
		url,
		config.SftpUser,
		config.SftpPass,
		config.Output,
		filePaths,
	)
}

// UploadFiles uploads a list of files by path sequentially in the order given.
// After each successful upload, the resulting uploaded URL is printed to
// STDOUT. Any upload errors will cause an error to be printed to Stderr and
// exit with an error code.
func UploadFiles(mode, url, user, pass, output string, paths []string) {
	for _, path := range paths {
		f, err := os.Open(path)
		if err != nil {
			fmt.Fprintf(os.Stderr, "pcf: failed reading stdin: %v\n", err)
			fmt.Fprintf(os.Stderr, "failed opening %s: %v\n", path, err)
			os.Exit(1)
		}
		// create reader (that supports seek) from stdinBytes
		in := bytes.NewReader(inBytes)
		connection := login(ftpURL)
		store(ftpURL, in, connection, "file")
		exit(connection)
		// calculate the hash (setting the reader to byte 0 first)
		if _, err := in.Seek(0, 0); err != nil {
			fmt.Fprintf(os.Stderr, "pcf: failed to read: %v\n", err)

		var buf bytes.Buffer
		tee := io.TeeReader(f, &buf)

		name := filepath.Base(path)
		if name == "" || name == "." {
			name = RandString(5)
		}

		req, err := NewRequest(mode, url, user, pass, name, tee)
		if err != nil {
			fmt.Fprintf(os.Stderr, "failed creating upload request for %s: %v\n", path, err)
			os.Exit(1)
		}
		h := hash(in)
		webURL := *ftpURL
		webURL.Host = ftpURL.Hostname()
		webURL.Path = h
		fmt.Println(&webURL)
	} else {
		// loop through and use all arguments
		connection := login(ftpURL)
		for _, arg := range args {
			file, err := os.Open(arg)
			if err != nil {
				fmt.Fprintf(os.Stderr, "pcf: open: %v\n", err)
				continue
			}
			defer file.Close()

			// store the file
			store(ftpURL, file, connection, filepath.Base(arg))

			// calculate the hash (setting the reader to byte 0 first)
			if _, err := file.Seek(0, 0); err != nil {
				fmt.Fprintf(os.Stderr, "pcf: failed to read: %v\n", err)
				os.Exit(1)
			}
			h := hash(file)

			// print the URL
			webURL := *ftpURL
			webURL.Host = ftpURL.Hostname()
			webURL.Path = h + filepath.Ext(arg)
			fmt.Println(&webURL)

		err = req.Upload()
		if err != nil {
			fmt.Fprintf(os.Stderr, "failed while uploading %s: %v\n", path, err)
			os.Exit(1)
		}
		exit(connection)
	}
}

// create the ftp connection
func login(u *url.URL) *ftp.ServerConn {
	c, err := ftp.Dial(u.Host, ftp.DialWithTimeout(10*time.Second))
	if err != nil {
		fmt.Fprintf(os.Stderr, "pcf: %v\n", err)
	}
	err = c.Login("anonymous", "anonymous")
	if err != nil {
		fmt.Fprintf(os.Stderr, "pcf: login: %v\n", err)
	}
	return c
}
		if err := f.Close(); err != nil {
			fmt.Fprintf(os.Stderr, "failed closing %s: %v\n", path, err)
			os.Exit(1)
		}

// store the file in the connection
func store(u *url.URL, r io.Reader, c *ftp.ServerConn, n string) {
	err := c.Stor(path.Join(u.Path, n), r)
	if err != nil {
		fmt.Fprintf(os.Stderr, "pcf: put: %v\n", err)
		// Success! Print the output URL.
		fmt.Println(HashName(&buf, path, output))
	}
}

// calculate and print the hash
func hash(f io.Reader) string {
	h := sha1.New()
	if _, err := io.Copy(h, f); err != nil {
		fmt.Fprintf(os.Stderr, "pcf: failed to hash: %v\n", err)
// RandString returns a random latin string of a variable length.
func RandString(n int) string {
	const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
	b := make([]byte, n)
	for i := range b {
		b[i] = letterBytes[rand.Intn(len(letterBytes))]
	}
	return fmt.Sprintf("%x", h.Sum(nil))
	return string(b)
}

// close the ftp connection
func exit(c *ftp.ServerConn) {
	err := c.Quit()
	if err != nil {
		fmt.Fprintf(os.Stderr, "pcf: quit: %v\n", err)
// HashName combines a prefix, SHA1 hash from an io.Reader, and the file
// extension from a path.
//
// The result is typically a URL indicating where the uploaded file can be
// located.
func HashName(f io.Reader, path string, prefix string) string {
	var b strings.Builder
	b.WriteString(prefix)

	hash := sha1.New()
	if _, err := io.Copy(hash, f); err != nil {
		fmt.Fprintf(
			os.Stderr,
			"failed calculating hash for %s: %v\n",
			path,
			err,
		)
	}
	b.WriteString(fmt.Sprintf("%x", hash.Sum(nil)))

	b.WriteString(Ext(path))
	return b.String()
}

// ReadAll is from go 1.16
// Implementing it ourself allows building in older go versions.
func ReadAll(r io.Reader) ([]byte, error) {
	b := make([]byte, 0, 512)
	for {
		if len(b) == cap(b) {
			// Add more capacity (let append pick how much).
			b = append(b, 0)[:len(b)]
		}
		n, err := r.Read(b[len(b):cap(b)])
		b = b[:len(b)+n]
		if err != nil {
			if err == io.EOF {
				err = nil
			}
			return b, err
		}
	}
// Ext returns the file name extension used by path.
// Unlike filepath.Ext we return a blank string for hidden files without an
// extension (.gitignore for example is considered to not have an extension).
func Ext(path string) string {
	path = strings.TrimPrefix(path, ".")
	return filepath.Ext(path)
}

A request.go => request.go +176 -0
@@ 0,0 1,176 @@
// pcf - A command line sha1/(S)FTP-based pastebin client.
// Copyright (C) 2022 Dakota Walsh
// GPL3+ See LICENSE in this repo for details.
package main

import (
	"fmt"
	"io"
	neturl "net/url"
	"path/filepath"
	"time"

	"github.com/jlaffaye/ftp"
	"github.com/pkg/sftp"
	"golang.org/x/crypto/ssh"
)

// A Request to upload a file.
type Request struct {
	// Mode describes the type of request to be made.
	Mode RequestMode

	// URL encodes the upload server, path, username, and password. The path is
	// used to change the directory where the file will be written on the
	// server.
	URL *neturl.URL

	// Name to store the file as on the remote server. This name is overwritten
	// in all modes except RequestModeSFTPAuth. For these other modes you should
	// use a random string in order to not have your file overwritten if another
	// user is simultaneously uploading.
	Name string

	// Content is the data to be uploaded.
	Content io.Reader
}

// RequestMode describes the type of request.
type RequestMode uint8

const (
	// RequestModeFTP is a RequestMode that uploads to an FTP server with an
	// username (defaulting to anonymous) and no password.
	RequestModeFTP RequestMode = iota

	// RequestModeSFTPAnon is a RequestMode that uploads to an SFTP server with
	// an username (defaulting to anonymous) and no password.
	RequestModeSFTPAnon

	// RequestModeSFTPAuth is a RequestMode that uploads to an SFTP server with
	// a username and password.
	RequestModeSFTPAuth
)

// NewRequest returns a new Request given a mode, url, file name, an optional
// username and password and an io.Reader.
//
// Mode is a string representation of a request mode:
// "ftp"       = RequestModeFTP
// "sftp-anon" = RequestModeSFTPAnon
// "sftp-auth" = RequestModeSFTPAuth
// If an invalid mode is provided "ftp" is used.
//
// Usernames and passwords encoded within the URL will be used for any of the
// three modes. However, if user or pass strings are given and the mode is
// "sftp-auth" they will override the encoded credentials. For all other modes
// given credentials are ignored.
func NewRequest(mode string, url string, user string, pass string, name string, content io.Reader) (*Request, error) {
	r := &Request{}
	var err error

	// Set mode and parse url.
	switch mode {
	case "sftp-auth":
		r.Mode = RequestModeSFTPAuth
	case "sftp-anon":
		r.Mode = RequestModeSFTPAnon
	default:
		r.Mode = RequestModeFTP
	}
	r.URL, err = neturl.Parse(url)

	// Replace URL encoded username and password if provided.
	if r.Mode == RequestModeSFTPAuth && user != "" {
		r.URL.User = neturl.UserPassword(user, pass)
	}

	r.Name = name
	r.Content = content
	return r, err
}

// Upload processes a request by uploading it to the FTP or SFTP server and then
// returns nil for a successful upload; or an error.
func (req *Request) Upload() error {
	if req.Mode == RequestModeFTP {
		return req.uploadFTP()
	}
	return req.uploadSFTP()
}

// uploadFTP processes a request using FTP to upload the file.
func (req *Request) uploadFTP() error {
	conn, err := ftp.Dial(req.URL.Host, ftp.DialWithTimeout(10*time.Second))
	if err != nil {
		return fmt.Errorf("connecting to %v: %w", req.URL.Host, err)
	}

	// Check if a password was provided in the URL. If so, use it instead of the
	// default username and password.
	var pass, user string
	if urlPass, ok := req.URL.User.Password(); ok {
		pass = urlPass
		user = req.URL.User.Username()
	} else {
		pass = "anonymous"
		user = "anonymous"
	}

	if err := conn.Login(user, pass); err != nil {
		return fmt.Errorf("logging into anonymous ftp with %s %s: %v", user, pass, err)
	}

	// Store the content, in the path provided in the URL.
	return conn.Stor(filepath.Join(req.URL.Path, req.Name), req.Content)
}

func (req *Request) uploadSFTP() error {
	// Default anonymous username and blank password.
	user := "anonymous"
	pass := ""

	// Check if a password was provided in the URL.
	urlPass, ok := req.URL.User.Password()
	if !ok {
		if req.Mode == RequestModeSFTPAuth {
			return fmt.Errorf("missing sftp password for authenticated sftp mode")
		}
	} else {
		user = req.URL.User.Username()
		pass = urlPass
	}

	config := &ssh.ClientConfig{
		User: user,
		Auth: []ssh.AuthMethod{
			ssh.Password(pass),
		},
		HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: store hostkeys
	}

	// An ssh connection is needed to start sftp.
	conn, err := ssh.Dial("tcp", req.URL.Host, config)
	if err != nil {
		return fmt.Errorf("connecting to %v: %w", req.URL.Host, err)
	}
	defer conn.Close()

	// Start sftp and then upload the file to the given path and name.
	client, err := sftp.NewClient(conn)
	if err != nil {
		return fmt.Errorf("failed initializing sftp in ssh session: %v", err)
	}

	path := sftp.Join(req.URL.Path, req.Name)
	f, err := client.Create(path)
	if err != nil {
		return fmt.Errorf("failed creating file at path %s: %v", path, err)
	}

	_, err = f.ReadFrom(req.Content)
	if err != nil {
		return fmt.Errorf("failed writing content to file in sftp connection: %v", err)
	}
	return client.Close()
}