feat: add FTP and SFTP upload support
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
// Package ftp provides the FTP side of SnapGo's FTP/SFTP destination.
|
||||
//
|
||||
// Design rationale:
|
||||
// - Plain FTP needs a real protocol client for PASV/EPSV handling; hand-
|
||||
// rolling those details would be fragile across common servers.
|
||||
// - SFTP is delegated to the sibling SSH adapter so authentication, agent,
|
||||
// key discovery, and known_hosts behaviour stay consistent with SCP.
|
||||
// - Both implementations upload to a temporary name and rename only after
|
||||
// the payload is complete, preventing a failed transfer from exposing a
|
||||
// partially written screenshot at the final path.
|
||||
package ftp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
ftpclient "github.com/jlaffaye/ftp"
|
||||
|
||||
"github.com/mmmy/snapgo/internal/domain"
|
||||
sshpkg "github.com/mmmy/snapgo/internal/infrastructure/ssh"
|
||||
)
|
||||
|
||||
// RemoteUploader is the common capability exposed to app.go for FTP and
|
||||
// SFTP. The application service only needs Upload; TestConnection remains an
|
||||
// infrastructure concern used by the Settings probe RPC.
|
||||
type RemoteUploader interface {
|
||||
Upload(ctx context.Context, remoteRelPath string, data []byte) error
|
||||
TestConnection(ctx context.Context) error
|
||||
}
|
||||
|
||||
// NewUploader validates cfg and returns the adapter for its selected protocol.
|
||||
func NewUploader(cfg domain.FTPConfig) (RemoteUploader, error) {
|
||||
normalized, err := normalizeConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if normalized.Protocol == domain.FTPProtocolSFTP {
|
||||
return sshpkg.NewSFTPUploader(normalized), nil
|
||||
}
|
||||
return &plainUploader{cfg: normalized}, nil
|
||||
}
|
||||
|
||||
type plainUploader struct {
|
||||
cfg domain.FTPConfig
|
||||
}
|
||||
|
||||
func ftpLog() *slog.Logger { return slog.Default().With("component", "ftp") }
|
||||
|
||||
func normalizeConfig(cfg domain.FTPConfig) (domain.FTPConfig, error) {
|
||||
cfg.Protocol = strings.ToLower(strings.TrimSpace(cfg.Protocol))
|
||||
if cfg.Protocol == "" {
|
||||
cfg.Protocol = domain.FTPProtocolFTP
|
||||
}
|
||||
if cfg.Protocol != domain.FTPProtocolFTP && cfg.Protocol != domain.FTPProtocolSFTP {
|
||||
return domain.FTPConfig{}, fmt.Errorf("file transfer: protocol must be ftp or sftp")
|
||||
}
|
||||
cfg.Host = strings.TrimSpace(cfg.Host)
|
||||
cfg.User = strings.TrimSpace(cfg.User)
|
||||
if cfg.Host == "" || cfg.User == "" {
|
||||
return domain.FTPConfig{}, fmt.Errorf("file transfer: host and user are required")
|
||||
}
|
||||
if cfg.Port <= 0 {
|
||||
if cfg.Protocol == domain.FTPProtocolSFTP {
|
||||
cfg.Port = 22
|
||||
} else {
|
||||
cfg.Port = 21
|
||||
}
|
||||
}
|
||||
if cfg.Port > 65535 {
|
||||
return domain.FTPConfig{}, fmt.Errorf("file transfer: port must be between 1 and 65535")
|
||||
}
|
||||
if cfg.ConnectTimeoutSecs <= 0 {
|
||||
cfg.ConnectTimeoutSecs = 10
|
||||
}
|
||||
if cfg.AuthMethod == "" {
|
||||
cfg.AuthMethod = domain.SSHAuthPassword
|
||||
}
|
||||
if cfg.Protocol == domain.FTPProtocolSFTP &&
|
||||
cfg.AuthMethod != domain.SSHAuthPassword && cfg.AuthMethod != domain.SSHAuthKey {
|
||||
return domain.FTPConfig{}, fmt.Errorf("sftp: auth method must be password or key")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (u *plainUploader) dial(ctx context.Context) (*ftpclient.ServerConn, error) {
|
||||
timeout := time.Duration(u.cfg.ConnectTimeoutSecs) * time.Second
|
||||
addr := net.JoinHostPort(u.cfg.Host, fmt.Sprintf("%d", u.cfg.Port))
|
||||
ftpLog().Info("FTP dial start",
|
||||
"addr", addr,
|
||||
"user", u.cfg.User,
|
||||
"timeout", timeout,
|
||||
"has_password", u.cfg.Password != "")
|
||||
conn, err := ftpclient.Dial(
|
||||
addr,
|
||||
ftpclient.DialWithDialFunc(contextDialFunc(ctx, timeout)),
|
||||
ftpclient.DialWithShutTimeout(timeout),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ftp: dial %s: %w", addr, err)
|
||||
}
|
||||
if err := conn.Login(u.cfg.User, u.cfg.Password); err != nil {
|
||||
_ = conn.Quit()
|
||||
return nil, fmt.Errorf("ftp: login: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// Upload stores one screenshot through plain FTP.
|
||||
func (u *plainUploader) Upload(ctx context.Context, remoteRelPath string, data []byte) error {
|
||||
cleaned, err := validateRemoteRelativePath(remoteRelPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn, err := u.dial(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = conn.Quit() }()
|
||||
|
||||
if err := enterFTPDirectory(conn, path.Dir(cleaned)); err != nil {
|
||||
return err
|
||||
}
|
||||
base := path.Base(cleaned)
|
||||
|
||||
tempName := temporaryRemoteName(base)
|
||||
removeTemp := true
|
||||
defer func() {
|
||||
if removeTemp {
|
||||
_ = conn.Delete(tempName)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := conn.Stor(tempName, &contextReader{ctx: ctx, reader: bytes.NewReader(data)}); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return fmt.Errorf("ftp: store %s: %w", cleaned, err)
|
||||
}
|
||||
if err := conn.Rename(tempName, base); err != nil {
|
||||
return fmt.Errorf("ftp: commit %s: %w", cleaned, err)
|
||||
}
|
||||
removeTemp = false
|
||||
ftpLog().Info("FTP upload ok", "remote_path", cleaned, "size", len(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestConnection writes and removes a small probe under the configured path,
|
||||
// proving that credentials and directory permissions are both usable.
|
||||
func (u *plainUploader) TestConnection(ctx context.Context) error {
|
||||
probe, err := probeRemotePath(u.cfg.PathPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn, err := u.dial(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = conn.Quit() }()
|
||||
|
||||
if err := enterFTPDirectory(conn, path.Dir(probe)); err != nil {
|
||||
return err
|
||||
}
|
||||
base := path.Base(probe)
|
||||
if err := conn.Stor(base, strings.NewReader("snapgo")); err != nil {
|
||||
return fmt.Errorf("ftp probe: write: %w", err)
|
||||
}
|
||||
if err := conn.Delete(base); err != nil {
|
||||
return fmt.Errorf("ftp probe: delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enterFTPDirectory walks to dir relative to the login root, creating missing
|
||||
// segments.
|
||||
func enterFTPDirectory(conn *ftpclient.ServerConn, dir string) error {
|
||||
dir = strings.Trim(dir, "/")
|
||||
if dir == "" || dir == "." {
|
||||
return nil
|
||||
}
|
||||
for _, segment := range strings.Split(dir, "/") {
|
||||
if err := conn.ChangeDir(segment); err == nil {
|
||||
continue
|
||||
}
|
||||
if err := conn.MakeDir(segment); err != nil {
|
||||
// A concurrent uploader may have created the directory after our
|
||||
// failed CWD. Retrying CWD distinguishes that race from a real error.
|
||||
if retryErr := conn.ChangeDir(segment); retryErr == nil {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("ftp: create directory %q: %w", segment, err)
|
||||
}
|
||||
if err := conn.ChangeDir(segment); err != nil {
|
||||
return fmt.Errorf("ftp: enter directory %q: %w", segment, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRemoteRelativePath(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || strings.HasPrefix(value, "/") || strings.HasPrefix(value, "~") {
|
||||
return "", fmt.Errorf("file transfer: remote path must be relative to the login directory")
|
||||
}
|
||||
if strings.ContainsAny(value, "\x00\r\n\\") {
|
||||
return "", fmt.Errorf("file transfer: remote path contains an invalid character")
|
||||
}
|
||||
for _, segment := range strings.Split(value, "/") {
|
||||
if segment == ".." {
|
||||
return "", fmt.Errorf("file transfer: remote path must not contain '..'")
|
||||
}
|
||||
}
|
||||
cleaned := path.Clean(value)
|
||||
if cleaned == "." || path.Base(cleaned) == "." || path.Base(cleaned) == ".." {
|
||||
return "", fmt.Errorf("file transfer: remote filename is empty")
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
func probeRemotePath(prefix string) (string, error) {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
prefix = strings.TrimPrefix(prefix, "~")
|
||||
prefix = strings.TrimLeft(prefix, "/")
|
||||
probe := fmt.Sprintf(".snapgo-probe-%d", time.Now().UnixNano())
|
||||
return validateRemoteRelativePath(path.Join(prefix, probe))
|
||||
}
|
||||
|
||||
func temporaryRemoteName(base string) string {
|
||||
return fmt.Sprintf(".%s.snapgo-upload-%d", base, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
type contextReader struct {
|
||||
ctx context.Context
|
||||
reader io.Reader
|
||||
}
|
||||
|
||||
func contextDialFunc(ctx context.Context, timeout time.Duration) func(string, string) (net.Conn, error) {
|
||||
dialer := &net.Dialer{Timeout: timeout}
|
||||
return func(network, address string) (net.Conn, error) {
|
||||
conn, err := dialer.DialContext(ctx, network, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wrapped := &cancelableConn{
|
||||
Conn: conn,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = wrapped.Close()
|
||||
case <-wrapped.done:
|
||||
}
|
||||
}()
|
||||
return wrapped, nil
|
||||
}
|
||||
}
|
||||
|
||||
type cancelableConn struct {
|
||||
net.Conn
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (c *cancelableConn) Close() error {
|
||||
var err error
|
||||
c.once.Do(func() {
|
||||
close(c.done)
|
||||
err = c.Conn.Close()
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *contextReader) Read(p []byte) (int, error) {
|
||||
select {
|
||||
case <-r.ctx.Done():
|
||||
return 0, r.ctx.Err()
|
||||
default:
|
||||
return r.reader.Read(p)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user