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