feat: add FTP and SFTP upload support

This commit is contained in:
2026-07-12 10:04:21 +08:00
parent f1998fb23c
commit dd12521be2
22 changed files with 1466 additions and 44 deletions
+75 -5
View File
@@ -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) {
+50
View File
@@ -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")
}
}