// 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) } }