146 lines
4.4 KiB
Go
146 lines
4.4 KiB
Go
// 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)
|
|
}
|
|
}
|