feat: add FTP and SFTP upload support

This commit is contained in:
2026-07-12 10:04:21 +08:00
parent f1998fb23c
commit dd12521be2
22 changed files with 1466 additions and 44 deletions
+288
View File
@@ -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)
}
}
@@ -0,0 +1,34 @@
package ftp
import "testing"
func TestValidateRemoteRelativePath(t *testing.T) {
valid, err := validateRemoteRelativePath("snapgo/2026/07/image.png")
if err != nil {
t.Fatalf("valid path rejected: %v", err)
}
if valid != "snapgo/2026/07/image.png" {
t.Fatalf("unexpected normalized path %q", valid)
}
invalid := []string{
"",
"/absolute/image.png",
"~/image.png",
"../image.png",
"snapgo/../../image.png",
"snapgo\\image.png",
"snapgo/image.png\nnext",
}
for _, value := range invalid {
if _, err := validateRemoteRelativePath(value); err == nil {
t.Errorf("expected %q to be rejected", value)
}
}
}
func TestProbeRemotePathRejectsTraversalPrefix(t *testing.T) {
if _, err := probeRemotePath("../../outside"); err == nil {
t.Fatal("expected traversal prefix to fail")
}
}
@@ -0,0 +1,213 @@
package ssh
import (
"bytes"
"context"
"fmt"
"io"
"os"
"path"
"strings"
"time"
"github.com/pkg/sftp"
"github.com/mmmy/snapgo/internal/domain"
)
// SFTPUploader transfers screenshots through the SSH File Transfer Protocol.
// It lives beside the SCP client so both protocols share the same SSH
// authentication and host-key implementation without duplicating credential
// discovery code.
type SFTPUploader struct {
cfg domain.FTPConfig
}
// NewSFTPUploader returns a short-lived SFTP adapter. A fresh SSH/SFTP
// connection is opened for every upload or connection probe.
func NewSFTPUploader(cfg domain.FTPConfig) *SFTPUploader {
return &SFTPUploader{cfg: cfg}
}
func (u *SFTPUploader) sshConfig() domain.SSHConfig {
return domain.SSHConfig{
Host: u.cfg.Host,
Port: u.cfg.Port,
User: u.cfg.User,
AuthMethod: u.cfg.AuthMethod,
Password: u.cfg.Password,
StrictHostKey: u.cfg.StrictHostKey,
KnownHostsPath: u.cfg.KnownHostsPath,
ConnectTimeoutSecs: u.cfg.ConnectTimeoutSecs,
}
}
func (u *SFTPUploader) dial(ctx context.Context) (*Client, *sftp.Client, error) {
sshClient, err := Dial(ctx, u.sshConfig())
if err != nil {
return nil, nil, fmt.Errorf("sftp: %w", err)
}
sftpClient, err := sftp.NewClient(sshClient.client)
if err != nil {
_ = sshClient.Close()
return nil, nil, fmt.Errorf("sftp: start subsystem: %w", err)
}
return sshClient, sftpClient, nil
}
// Upload writes data to a temporary file and atomically exposes it at the
// final relative path after the transfer completes.
func (u *SFTPUploader) Upload(ctx context.Context, remoteRelPath string, data []byte) error {
cleaned, err := validateSFTPRemotePath(remoteRelPath)
if err != nil {
return err
}
sshClient, sftpClient, err := u.dial(ctx)
if err != nil {
return err
}
done := closeSFTPOnCancel(ctx, sshClient, sftpClient)
defer sshClient.Close()
defer sftpClient.Close()
defer close(done)
dir := path.Dir(cleaned)
if dir != "." {
if err := sftpClient.MkdirAll(dir); err != nil {
return fmt.Errorf("sftp: create directory %s: %w", dir, err)
}
}
tempPath := path.Join(dir, temporarySFTPName(path.Base(cleaned)))
removeTemp := true
defer func() {
if removeTemp {
_ = sftpClient.Remove(tempPath)
}
}()
file, err := sftpClient.OpenFile(tempPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
if err != nil {
return fmt.Errorf("sftp: create temporary file: %w", err)
}
reader := &sftpContextReader{ctx: ctx, reader: bytes.NewReader(data)}
_, copyErr := io.Copy(file, reader)
if copyErr == nil {
copyErr = file.Chmod(0o644)
}
closeErr := file.Close()
if copyErr != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("sftp: write %s: %w", cleaned, copyErr)
}
if closeErr != nil {
return fmt.Errorf("sftp: close %s: %w", cleaned, closeErr)
}
if err := sftpClient.Rename(tempPath, cleaned); err != nil {
return fmt.Errorf("sftp: commit %s: %w", cleaned, err)
}
removeTemp = false
sshLog().Info("SFTP upload ok", "remote_path", cleaned, "size", len(data))
return nil
}
// TestConnection verifies SFTP write/delete permissions with a small probe.
func (u *SFTPUploader) TestConnection(ctx context.Context) error {
probe, err := sftpProbePath(u.cfg.PathPrefix)
if err != nil {
return err
}
sshClient, sftpClient, err := u.dial(ctx)
if err != nil {
return err
}
done := closeSFTPOnCancel(ctx, sshClient, sftpClient)
defer sshClient.Close()
defer sftpClient.Close()
defer close(done)
dir := path.Dir(probe)
if dir != "." {
if err := sftpClient.MkdirAll(dir); err != nil {
return fmt.Errorf("sftp probe: create directory: %w", err)
}
}
file, err := sftpClient.OpenFile(probe, os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
if err != nil {
return fmt.Errorf("sftp probe: create: %w", err)
}
_, writeErr := file.Write([]byte("snapgo"))
closeErr := file.Close()
if writeErr != nil {
_ = sftpClient.Remove(probe)
return fmt.Errorf("sftp probe: write: %w", writeErr)
}
if closeErr != nil {
_ = sftpClient.Remove(probe)
return fmt.Errorf("sftp probe: close: %w", closeErr)
}
if err := sftpClient.Remove(probe); err != nil {
return fmt.Errorf("sftp probe: delete: %w", err)
}
return nil
}
func closeSFTPOnCancel(ctx context.Context, sshClient *Client, sftpClient *sftp.Client) chan struct{} {
done := make(chan struct{})
go func() {
select {
case <-ctx.Done():
_ = sftpClient.Close()
_ = sshClient.Close()
case <-done:
}
}()
return done
}
func validateSFTPRemotePath(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" || strings.HasPrefix(value, "/") || strings.HasPrefix(value, "~") {
return "", fmt.Errorf("sftp: remote path must be relative to the login directory")
}
if strings.ContainsAny(value, "\x00\r\n\\") {
return "", fmt.Errorf("sftp: remote path contains an invalid character")
}
for _, segment := range strings.Split(value, "/") {
if segment == ".." {
return "", fmt.Errorf("sftp: remote path must not contain '..'")
}
}
cleaned := path.Clean(value)
if cleaned == "." || path.Base(cleaned) == "." || path.Base(cleaned) == ".." {
return "", fmt.Errorf("sftp: remote filename is empty")
}
return cleaned, nil
}
func sftpProbePath(prefix string) (string, error) {
prefix = strings.TrimSpace(prefix)
prefix = strings.TrimPrefix(prefix, "~")
prefix = strings.TrimLeft(prefix, "/")
return validateSFTPRemotePath(path.Join(prefix, fmt.Sprintf(".snapgo-probe-%d", time.Now().UnixNano())))
}
func temporarySFTPName(base string) string {
return fmt.Sprintf(".%s.snapgo-upload-%d", base, time.Now().UnixNano())
}
type sftpContextReader struct {
ctx context.Context
reader io.Reader
}
func (r *sftpContextReader) Read(p []byte) (int, error) {
select {
case <-r.ctx.Done():
return 0, r.ctx.Err()
default:
return r.reader.Read(p)
}
}
@@ -0,0 +1,28 @@
package ssh
import "testing"
func TestValidateSFTPRemotePathRejectsTraversal(t *testing.T) {
if got, err := validateSFTPRemotePath("snapgo/2026/image.png"); err != nil || got != "snapgo/2026/image.png" {
t.Fatalf("valid path: got %q err=%v", got, err)
}
for _, value := range []string{
"",
"/absolute/image.png",
"~/image.png",
"../image.png",
"snapgo/../../image.png",
"snapgo\\image.png",
} {
if _, err := validateSFTPRemotePath(value); err == nil {
t.Errorf("expected %q to be rejected", value)
}
}
}
func TestSFTPProbePathRejectsTraversalPrefix(t *testing.T) {
if _, err := sftpProbePath("../../outside"); err == nil {
t.Fatal("expected traversal prefix to fail")
}
}