feat: add FTP and SFTP upload support
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
// capture_ftp.go — application-level FTP/SFTP upload orchestration.
|
||||
//
|
||||
// Design rationale:
|
||||
// - FTP/SFTP returns a remote filesystem path, not necessarily a public URL,
|
||||
// so this flow stays separate from the OSSProvider-based S3 pipeline.
|
||||
// - A tiny uploader interface keeps protocol libraries out of the
|
||||
// application layer and makes the upload/copy/notify sequence unit-testable.
|
||||
// - The dated filename layout is shared with SSH/SCP while FTP-specific path
|
||||
// validation prevents an invalid prefix from escaping the login root.
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mmmy/snapgo/internal/domain"
|
||||
"github.com/mmmy/snapgo/internal/infrastructure/clipboard"
|
||||
)
|
||||
|
||||
// ftpSvcLog returns an application-layer FTP logger bound to the current
|
||||
// default slog handler.
|
||||
func ftpSvcLog() *slog.Logger { return slog.Default().With("component", "application.ftp") }
|
||||
|
||||
// FTPUploader abstracts both FTP and SFTP adapters.
|
||||
type FTPUploader interface {
|
||||
// Upload stores data at remoteRelPath, relative to the authenticated
|
||||
// account's login root.
|
||||
Upload(ctx context.Context, remoteRelPath string, data []byte) error
|
||||
}
|
||||
|
||||
// CaptureAndFTPService wires captured PNG bytes → FTP/SFTP → clipboard →
|
||||
// notification.
|
||||
type CaptureAndFTPService struct {
|
||||
Uploader FTPUploader
|
||||
Clipboard clipboard.Writer
|
||||
Notifier Notifier
|
||||
Cfg domain.FTPConfig
|
||||
}
|
||||
|
||||
// ExecuteWithBytes uploads previously captured PNG bytes and copies the exact
|
||||
// remote path semantics shown in Settings: FTP paths are rooted at "/", while
|
||||
// SFTP paths are relative to the authenticated user's home directory.
|
||||
func (s *CaptureAndFTPService) ExecuteWithBytes(ctx context.Context, pngBytes []byte) error {
|
||||
if s.Uploader == nil {
|
||||
s.notifyFailure("FTP/SFTP not configured")
|
||||
return fmt.Errorf("ftp uploader is nil")
|
||||
}
|
||||
if len(pngBytes) == 0 {
|
||||
s.notifyFailure("empty screenshot")
|
||||
return fmt.Errorf("empty screenshot")
|
||||
}
|
||||
|
||||
relPath, err := buildFTPRemotePath(s.Cfg.PathPrefix)
|
||||
if err != nil {
|
||||
s.notifyFailure(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
protocol := normalizedFTPProtocol(s.Cfg.Protocol)
|
||||
start := time.Now()
|
||||
ftpSvcLog().Info("file-transfer pipeline start",
|
||||
"protocol", protocol,
|
||||
"host", s.Cfg.Host,
|
||||
"user", s.Cfg.User,
|
||||
"port", s.Cfg.Port,
|
||||
"size", len(pngBytes),
|
||||
"remote_path", relPath,
|
||||
"strict_host_key", s.Cfg.StrictHostKey)
|
||||
|
||||
if err := s.Uploader.Upload(ctx, relPath, pngBytes); err != nil {
|
||||
ftpSvcLog().Error("file-transfer upload failed",
|
||||
"protocol", protocol,
|
||||
"remote_path", relPath,
|
||||
"elapsed", time.Since(start),
|
||||
"err", err)
|
||||
s.notifyFailure(strings.ToUpper(protocol) + " upload failed: " + err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
clipText := ftpShareText(protocol, relPath)
|
||||
if s.Clipboard != nil {
|
||||
if err := s.Clipboard.WriteText(clipText); err != nil {
|
||||
s.notifyFailure("clipboard write failed: " + err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
if s.Notifier != nil {
|
||||
s.Notifier.NotifySuccess(clipText)
|
||||
}
|
||||
ftpSvcLog().Info("file-transfer pipeline done",
|
||||
"protocol", protocol,
|
||||
"remote_path", relPath,
|
||||
"total_elapsed", time.Since(start))
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildFTPRemotePath validates the user-controlled prefix before reusing the
|
||||
// existing dated remote-path generator shared with SSH/SCP.
|
||||
func buildFTPRemotePath(prefix string) (string, error) {
|
||||
if err := validateFTPPathPrefix(prefix); err != nil {
|
||||
return "", err
|
||||
}
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
prefix = strings.TrimPrefix(prefix, "~")
|
||||
prefix = strings.TrimLeft(prefix, "/")
|
||||
return buildRemoteRelPath(prefix), nil
|
||||
}
|
||||
|
||||
func validateFTPPathPrefix(prefix string) error {
|
||||
if strings.ContainsAny(prefix, "\x00\r\n\\") {
|
||||
return fmt.Errorf("FTP/SFTP path contains an invalid character")
|
||||
}
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
prefix = strings.TrimPrefix(prefix, "~")
|
||||
prefix = strings.TrimLeft(prefix, "/")
|
||||
for _, segment := range strings.Split(prefix, "/") {
|
||||
if segment == ".." {
|
||||
return fmt.Errorf("FTP/SFTP path must stay inside the login directory")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizedFTPProtocol(protocol string) string {
|
||||
if protocol == domain.FTPProtocolSFTP {
|
||||
return domain.FTPProtocolSFTP
|
||||
}
|
||||
return domain.FTPProtocolFTP
|
||||
}
|
||||
|
||||
func ftpShareText(protocol, relPath string) string {
|
||||
if protocol == domain.FTPProtocolSFTP {
|
||||
return "~/" + relPath
|
||||
}
|
||||
return "/" + relPath
|
||||
}
|
||||
|
||||
func (s *CaptureAndFTPService) notifyFailure(reason string) {
|
||||
if s.Notifier != nil {
|
||||
s.Notifier.NotifyFailure(reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mmmy/snapgo/internal/domain"
|
||||
)
|
||||
|
||||
type fakeFTPUploader struct {
|
||||
path string
|
||||
data []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeFTPUploader) Upload(_ context.Context, remoteRelPath string, data []byte) error {
|
||||
f.path = remoteRelPath
|
||||
f.data = append([]byte(nil), data...)
|
||||
return f.err
|
||||
}
|
||||
|
||||
func TestCaptureAndFTPServiceUploadsAndCopiesFTPPath(t *testing.T) {
|
||||
uploader := &fakeFTPUploader{}
|
||||
clip := &fakeClipboard{}
|
||||
notifier := &fakeNotifier{}
|
||||
svc := &CaptureAndFTPService{
|
||||
Uploader: uploader,
|
||||
Clipboard: clip,
|
||||
Notifier: notifier,
|
||||
Cfg: domain.FTPConfig{
|
||||
Protocol: domain.FTPProtocolFTP,
|
||||
PathPrefix: "snapgo/",
|
||||
},
|
||||
}
|
||||
|
||||
if err := svc.ExecuteWithBytes(context.Background(), []byte("png")); err != nil {
|
||||
t.Fatalf("upload FTP screenshot: %v", err)
|
||||
}
|
||||
if string(uploader.data) != "png" {
|
||||
t.Fatalf("expected PNG bytes, got %q", string(uploader.data))
|
||||
}
|
||||
if !strings.HasPrefix(uploader.path, "snapgo/") || !strings.HasSuffix(uploader.path, ".png") {
|
||||
t.Fatalf("unexpected remote path %q", uploader.path)
|
||||
}
|
||||
if clip.text != "/"+uploader.path {
|
||||
t.Fatalf("expected FTP root path copied, got %q", clip.text)
|
||||
}
|
||||
if notifier.success != clip.text {
|
||||
t.Fatalf("expected success notification %q, got %q", clip.text, notifier.success)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureAndFTPServiceCopiesSFTPHomePath(t *testing.T) {
|
||||
uploader := &fakeFTPUploader{}
|
||||
clip := &fakeClipboard{}
|
||||
svc := &CaptureAndFTPService{
|
||||
Uploader: uploader,
|
||||
Clipboard: clip,
|
||||
Cfg: domain.FTPConfig{
|
||||
Protocol: domain.FTPProtocolSFTP,
|
||||
PathPrefix: "images",
|
||||
},
|
||||
}
|
||||
|
||||
if err := svc.ExecuteWithBytes(context.Background(), []byte("png")); err != nil {
|
||||
t.Fatalf("upload SFTP screenshot: %v", err)
|
||||
}
|
||||
if clip.text != "~/"+uploader.path {
|
||||
t.Fatalf("expected SFTP home path copied, got %q", clip.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureAndFTPServiceRejectsTraversalBeforeUpload(t *testing.T) {
|
||||
uploader := &fakeFTPUploader{}
|
||||
notifier := &fakeNotifier{}
|
||||
svc := &CaptureAndFTPService{
|
||||
Uploader: uploader,
|
||||
Notifier: notifier,
|
||||
Cfg: domain.FTPConfig{
|
||||
Protocol: domain.FTPProtocolSFTP,
|
||||
PathPrefix: "../../outside",
|
||||
},
|
||||
}
|
||||
|
||||
err := svc.ExecuteWithBytes(context.Background(), []byte("png"))
|
||||
if err == nil {
|
||||
t.Fatal("expected traversal prefix to fail")
|
||||
}
|
||||
if uploader.path != "" {
|
||||
t.Fatalf("uploader should not run, got path %q", uploader.path)
|
||||
}
|
||||
if notifier.failure == "" {
|
||||
t.Fatal("expected failure notification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureAndFTPServiceSurfacesUploadFailure(t *testing.T) {
|
||||
want := errors.New("server unavailable")
|
||||
uploader := &fakeFTPUploader{err: want}
|
||||
clip := &fakeClipboard{}
|
||||
notifier := &fakeNotifier{}
|
||||
svc := &CaptureAndFTPService{
|
||||
Uploader: uploader,
|
||||
Clipboard: clip,
|
||||
Notifier: notifier,
|
||||
Cfg: domain.FTPConfig{Protocol: domain.FTPProtocolFTP},
|
||||
}
|
||||
|
||||
err := svc.ExecuteWithBytes(context.Background(), []byte("png"))
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("expected %v, got %v", want, err)
|
||||
}
|
||||
if clip.text != "" {
|
||||
t.Fatalf("clipboard should remain empty, got %q", clip.text)
|
||||
}
|
||||
if !strings.Contains(notifier.failure, "server unavailable") {
|
||||
t.Fatalf("unexpected failure notification %q", notifier.failure)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFTPRemotePathNormalizesLoginRootMarkers(t *testing.T) {
|
||||
remotePath, err := buildFTPRemotePath(" ~////snapgo/ ")
|
||||
if err != nil {
|
||||
t.Fatalf("build remote path: %v", err)
|
||||
}
|
||||
if strings.HasPrefix(remotePath, "/") || !strings.HasPrefix(remotePath, "snapgo/") {
|
||||
t.Fatalf("expected relative snapgo path, got %q", remotePath)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// Package domain — configuration types.
|
||||
//
|
||||
// S3Config and SSHConfig are intentionally split into their own structs so
|
||||
// that future providers can introduce their own configuration types side-
|
||||
// by-side without polluting the core domain types file.
|
||||
// S3Config, SSHConfig, and FTPConfig are intentionally split into their own
|
||||
// structs so future providers can introduce configuration types side-by-side
|
||||
// without polluting the core domain types file.
|
||||
package domain
|
||||
|
||||
// S3Config describes the connection parameters for any S3-compatible
|
||||
@@ -72,6 +72,32 @@ type SSHConfig struct {
|
||||
ConnectTimeoutSecs int `json:"connectTimeoutSecs"`
|
||||
}
|
||||
|
||||
// FTPConfig describes a file-transfer destination reached through FTP or
|
||||
// SFTP. It is intentionally separate from SSHConfig: SFTP uses the SSH
|
||||
// transport, but it is a different file-transfer protocol from the existing
|
||||
// SCP action and has its own remote-root semantics.
|
||||
//
|
||||
// Field design notes:
|
||||
// - Protocol is either "ftp" or "sftp". Their default ports are 21 and 22.
|
||||
// - AuthMethod is used only by SFTP. "password" uses Password, while "key"
|
||||
// reuses the local ssh-agent and ~/.ssh/id_* discovery used by SSH/SCP.
|
||||
// - PathPrefix is relative to the account's login root. The application
|
||||
// rejects absolute paths and traversal segments before uploading.
|
||||
// - StrictHostKey and KnownHostsPath apply only to SFTP. Plain FTP has no
|
||||
// host-key mechanism and sends credentials and data without encryption.
|
||||
type FTPConfig struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
AuthMethod string `json:"authMethod"`
|
||||
Password string `json:"password"`
|
||||
PathPrefix string `json:"pathPrefix"`
|
||||
StrictHostKey bool `json:"strictHostKey"`
|
||||
KnownHostsPath string `json:"knownHostsPath"`
|
||||
ConnectTimeoutSecs int `json:"connectTimeoutSecs"`
|
||||
}
|
||||
|
||||
// LLMProviderConfig stores one OpenAI-compatible multimodal chat endpoint.
|
||||
//
|
||||
// The three built-in providers (Qwen, Doubao/Ark, OpenAI-compatible) all use
|
||||
@@ -145,6 +171,12 @@ const (
|
||||
SSHAuthKerberos = "kerberos"
|
||||
)
|
||||
|
||||
// File-transfer protocol identifiers stored in FTPConfig.Protocol.
|
||||
const (
|
||||
FTPProtocolFTP = "ftp"
|
||||
FTPProtocolSFTP = "sftp"
|
||||
)
|
||||
|
||||
// Built-in LLM provider identifiers.
|
||||
const (
|
||||
LLMProviderQwen = "qwen"
|
||||
@@ -181,8 +213,8 @@ func (c SSHConfig) IsKerberos() bool {
|
||||
|
||||
// AppConfig is the top-level on-disk configuration document.
|
||||
//
|
||||
// We keep S3 / SSH nested so that adding more providers later (e.g. AliyunOSS,
|
||||
// COS, FTP) only requires a new sibling field rather than a schema rewrite.
|
||||
// We keep S3 / SSH / FTP nested so adding another provider later only requires
|
||||
// a new sibling field rather than a schema rewrite.
|
||||
type AppConfig struct {
|
||||
// Hotkey describes the global shortcut that triggers a capture.
|
||||
// Stored as a human-readable string like "cmd+shift+a"; parsing happens
|
||||
@@ -199,6 +231,9 @@ type AppConfig struct {
|
||||
// destination triggered by the save-remote toolbar button.
|
||||
SSH SSHConfig `json:"ssh"`
|
||||
|
||||
// FTP holds the destination used by the dedicated FTP/SFTP toolbar action.
|
||||
FTP FTPConfig `json:"ftp"`
|
||||
|
||||
// LLM holds provider settings for the "copy summary" screenshot action.
|
||||
LLM LLMConfig `json:"llm"`
|
||||
|
||||
@@ -221,6 +256,14 @@ func DefaultAppConfig() AppConfig {
|
||||
ConnectTimeoutSecs: 10,
|
||||
StrictHostKey: false,
|
||||
},
|
||||
FTP: FTPConfig{
|
||||
Protocol: FTPProtocolFTP,
|
||||
Port: 21,
|
||||
AuthMethod: SSHAuthPassword,
|
||||
PathPrefix: "snapgo/",
|
||||
ConnectTimeoutSecs: 10,
|
||||
StrictHostKey: false,
|
||||
},
|
||||
LLM: DefaultLLMConfig(),
|
||||
OCR: DefaultOCRConfig(),
|
||||
}
|
||||
@@ -315,6 +358,25 @@ func (c *AppConfig) Normalize() {
|
||||
if c.SSH.ConnectTimeoutSecs == 0 {
|
||||
c.SSH.ConnectTimeoutSecs = 10
|
||||
}
|
||||
if c.FTP.Protocol != FTPProtocolFTP && c.FTP.Protocol != FTPProtocolSFTP {
|
||||
c.FTP.Protocol = FTPProtocolFTP
|
||||
}
|
||||
if c.FTP.Port == 0 {
|
||||
if c.FTP.Protocol == FTPProtocolSFTP {
|
||||
c.FTP.Port = 22
|
||||
} else {
|
||||
c.FTP.Port = 21
|
||||
}
|
||||
}
|
||||
if c.FTP.AuthMethod != SSHAuthPassword && c.FTP.AuthMethod != SSHAuthKey {
|
||||
c.FTP.AuthMethod = SSHAuthPassword
|
||||
}
|
||||
if c.FTP.PathPrefix == "" {
|
||||
c.FTP.PathPrefix = "snapgo/"
|
||||
}
|
||||
if c.FTP.ConnectTimeoutSecs == 0 {
|
||||
c.FTP.ConnectTimeoutSecs = 10
|
||||
}
|
||||
|
||||
defaultLLM := DefaultLLMConfig()
|
||||
if c.LLM.ActiveProvider == "" {
|
||||
@@ -409,6 +471,14 @@ func (c AppConfig) IsSSHConfigured() bool {
|
||||
return c.SSH.Host != "" && c.SSH.User != ""
|
||||
}
|
||||
|
||||
// IsFTPConfigured reports whether the FTP/SFTP destination has the minimum
|
||||
// fields required to attempt a connection. Password is not required because
|
||||
// SFTP key authentication and password-less FTP accounts are both valid.
|
||||
func (c AppConfig) IsFTPConfigured() bool {
|
||||
return (c.FTP.Protocol == FTPProtocolFTP || c.FTP.Protocol == FTPProtocolSFTP) &&
|
||||
c.FTP.Host != "" && c.FTP.User != ""
|
||||
}
|
||||
|
||||
// ActiveLLMProvider returns the selected provider config plus a boolean
|
||||
// indicating whether the selection exists.
|
||||
func (c AppConfig) ActiveLLMProvider() (string, LLMProviderConfig, bool) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultAppConfigIncludesFTPDefaults(t *testing.T) {
|
||||
cfg := DefaultAppConfig()
|
||||
if cfg.FTP.Protocol != FTPProtocolFTP {
|
||||
t.Fatalf("expected default FTP protocol, got %q", cfg.FTP.Protocol)
|
||||
}
|
||||
if cfg.FTP.Port != 21 {
|
||||
t.Fatalf("expected default FTP port 21, got %d", cfg.FTP.Port)
|
||||
}
|
||||
if cfg.FTP.PathPrefix != "snapgo/" {
|
||||
t.Fatalf("expected default path prefix, got %q", cfg.FTP.PathPrefix)
|
||||
}
|
||||
if cfg.FTP.ConnectTimeoutSecs != 10 {
|
||||
t.Fatalf("expected 10 second timeout, got %d", cfg.FTP.ConnectTimeoutSecs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeUsesSFTPDefaultPort(t *testing.T) {
|
||||
cfg := AppConfig{
|
||||
FTP: FTPConfig{
|
||||
Protocol: FTPProtocolSFTP,
|
||||
},
|
||||
}
|
||||
cfg.Normalize()
|
||||
if cfg.FTP.Port != 22 {
|
||||
t.Fatalf("expected SFTP port 22, got %d", cfg.FTP.Port)
|
||||
}
|
||||
if cfg.FTP.AuthMethod != SSHAuthPassword {
|
||||
t.Fatalf("expected password auth default, got %q", cfg.FTP.AuthMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsFTPConfiguredRequiresProtocolHostAndUser(t *testing.T) {
|
||||
cfg := DefaultAppConfig()
|
||||
if cfg.IsFTPConfigured() {
|
||||
t.Fatal("empty FTP destination should not be configured")
|
||||
}
|
||||
cfg.FTP.Host = "files.example.com"
|
||||
cfg.FTP.User = "snapgo"
|
||||
if !cfg.IsFTPConfigured() {
|
||||
t.Fatal("host and user should be enough for a normalized FTP config")
|
||||
}
|
||||
cfg.FTP.Protocol = "invalid"
|
||||
if cfg.IsFTPConfigured() {
|
||||
t.Fatal("invalid protocol should not be configured")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user