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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user