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