From b69ef000134f7e0408305d80b8ad1989eecf028f Mon Sep 17 00:00:00 2001 From: mamamiyear Date: Mon, 13 Jul 2026 13:20:31 +0800 Subject: [PATCH] feat: add ftp root path setting --- frontend/src/App.vue | 2 +- frontend/src/views/SettingsView.vue | 50 +++++++++++++++++------- frontend/wailsjs/go/models.ts | 2 + internal/application/capture_ftp.go | 21 ++++++---- internal/application/capture_ftp_test.go | 39 +++++++++++++++--- internal/domain/config.go | 13 ++++++ internal/domain/config_ftp_test.go | 6 +++ 7 files changed, 104 insertions(+), 29 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 2d6679e..2c1b008 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -68,7 +68,7 @@ const activeTab = ref('general') const sidebarItems: Array<{ id: SettingsTab; label: string }> = [ { id: 'general', label: '通用设置' }, { id: 's3', label: '对象存储' }, - { id: 'ftp', label: 'FTP / SFTP' }, + { id: 'ftp', label: '文件服务' }, { id: 'ssh', label: '远程主机' }, { id: 'llm', label: '智能识图' }, { id: 'ocr', label: '文字提取' }, diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index ebb562b..a217860 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -69,6 +69,7 @@ interface FTPConfig { user: string authMethod: 'password' | 'key' password: string + rootDirectory: string pathPrefix: string strictHostKey: boolean knownHostsPath: string @@ -169,6 +170,7 @@ function defaultConfig(): AppConfig { user: '', authMethod: 'password', password: '', + rootDirectory: '/', pathPrefix: 'snapgo/', strictHostKey: false, knownHostsPath: '', @@ -271,8 +273,8 @@ const sshPathDisplay = computed({ }) // FTP and SFTP both upload relative to the authenticated login directory. -// The visible prefix communicates how that location will be copied: FTP uses -// its virtual root, while SFTP uses the SSH user's home directory. +// RootDirectory is display-only: it is prepended to the uploaded relative path +// when that path is copied, without changing where the file is uploaded. const ftpPathDisplay = computed({ get: () => config.value.ftp.pathPrefix, set: (raw: string) => { @@ -283,17 +285,26 @@ const ftpPathDisplay = computed({ }, }) -const ftpPathRootLabel = computed(() => - config.value.ftp.protocol === 'sftp' ? '~/' : '/' -) +const ftpCopiedPathPreview = computed(() => { + const root = config.value.ftp.rootDirectory.trim().replace(/\/+$/, '') + const remote = config.value.ftp.pathPrefix + .trim() + .replace(/^\/+|\/+$/g, '') + const remotePrefix = remote ? `${remote}/` : '' + return `${root || (config.value.ftp.protocol === 'sftp' ? '~' : '')}/${remotePrefix}2026/07/20260713-110413-18lnbx.png` +}) function changeFTPProtocol(event: Event) { const next = (event.target as HTMLSelectElement).value as FTPProtocol const previous = config.value.ftp.protocol const previousDefault = previous === 'sftp' ? 22 : 21 + const previousRootDefault = previous === 'sftp' ? '~/' : '/' if (config.value.ftp.port === previousDefault) { config.value.ftp.port = next === 'sftp' ? 22 : 21 } + if (config.value.ftp.rootDirectory === previousRootDefault) { + config.value.ftp.rootDirectory = next === 'sftp' ? '~/' : '/' + } config.value.ftp.protocol = next } @@ -531,7 +542,7 @@ onMounted(load)
-

FTP / SFTP destination

+

文件服务

+
+

+ 复制路径示例:{{ ftpCopiedPathPreview }} +

+

FTP sends the username, password, and screenshot without encryption. Prefer SFTP on networks you do not fully trust. SFTP is not FTPS. diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index b61be30..009b7d0 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -186,6 +186,7 @@ export namespace domain { user: string; authMethod: string; password: string; + rootDirectory: string; pathPrefix: string; strictHostKey: boolean; knownHostsPath: string; @@ -203,6 +204,7 @@ export namespace domain { this.user = source["user"]; this.authMethod = source["authMethod"]; this.password = source["password"]; + this.rootDirectory = source["rootDirectory"]; this.pathPrefix = source["pathPrefix"]; this.strictHostKey = source["strictHostKey"]; this.knownHostsPath = source["knownHostsPath"]; diff --git a/internal/application/capture_ftp.go b/internal/application/capture_ftp.go index da563f4..3df7a2a 100644 --- a/internal/application/capture_ftp.go +++ b/internal/application/capture_ftp.go @@ -40,9 +40,9 @@ type CaptureAndFTPService struct { 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. +// ExecuteWithBytes uploads previously captured PNG bytes and copies a display +// path built from the configured root directory plus the uploaded relative +// path. RootDirectory deliberately does not affect the upload destination. func (s *CaptureAndFTPService) ExecuteWithBytes(ctx context.Context, pngBytes []byte) error { if s.Uploader == nil { s.notifyFailure("FTP/SFTP not configured") @@ -80,7 +80,7 @@ func (s *CaptureAndFTPService) ExecuteWithBytes(ctx context.Context, pngBytes [] return err } - clipText := ftpShareText(protocol, relPath) + clipText := ftpShareText(protocol, s.Cfg.RootDirectory, relPath) if s.Clipboard != nil { if err := s.Clipboard.WriteText(clipText); err != nil { s.notifyFailure("clipboard write failed: " + err.Error()) @@ -131,11 +131,16 @@ func normalizedFTPProtocol(protocol string) string { return domain.FTPProtocolFTP } -func ftpShareText(protocol, relPath string) string { - if protocol == domain.FTPProtocolSFTP { - return "~/" + relPath +func ftpShareText(protocol, rootDirectory, relPath string) string { + rootDirectory = strings.TrimSpace(rootDirectory) + if rootDirectory == "" { + if protocol == domain.FTPProtocolSFTP { + rootDirectory = "~/" + } else { + rootDirectory = "/" + } } - return "/" + relPath + return strings.TrimRight(rootDirectory, "/") + "/" + strings.TrimLeft(relPath, "/") } func (s *CaptureAndFTPService) notifyFailure(reason string) { diff --git a/internal/application/capture_ftp_test.go b/internal/application/capture_ftp_test.go index 92eba5a..72dbcfe 100644 --- a/internal/application/capture_ftp_test.go +++ b/internal/application/capture_ftp_test.go @@ -52,23 +52,52 @@ func TestCaptureAndFTPServiceUploadsAndCopiesFTPPath(t *testing.T) { } } -func TestCaptureAndFTPServiceCopiesSFTPHomePath(t *testing.T) { +func TestCaptureAndFTPServiceCopiesConfiguredRootPath(t *testing.T) { uploader := &fakeFTPUploader{} clip := &fakeClipboard{} svc := &CaptureAndFTPService{ Uploader: uploader, Clipboard: clip, Cfg: domain.FTPConfig{ - Protocol: domain.FTPProtocolSFTP, - PathPrefix: "images", + Protocol: domain.FTPProtocolSFTP, + RootDirectory: "~/ftp/", + 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) + if clip.text != "~/ftp/"+uploader.path { + t.Fatalf("expected configured SFTP root path copied, got %q", clip.text) + } +} + +func TestFTPShareTextJoinsConfiguredRootAndRemoteDirectory(t *testing.T) { + const relPath = "snapgo/2026/07/20260713-110413-18lnbx.png" + const want = "~/ftp/snapgo/2026/07/20260713-110413-18lnbx.png" + if got := ftpShareText(domain.FTPProtocolSFTP, "~/ftp/", relPath); got != want { + t.Fatalf("ftpShareText() = %q, want %q", got, want) + } +} + +func TestFTPShareTextUsesProtocolDefaultWhenRootDirectoryIsEmpty(t *testing.T) { + const relPath = "snapgo/2026/07/image.png" + tests := []struct { + name string + protocol string + want string + }{ + {name: "FTP", protocol: domain.FTPProtocolFTP, want: "/" + relPath}, + {name: "SFTP", protocol: domain.FTPProtocolSFTP, want: "~/" + relPath}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ftpShareText(tt.protocol, "", relPath); got != tt.want { + t.Fatalf("ftpShareText() = %q, want %q", got, tt.want) + } + }) } } diff --git a/internal/domain/config.go b/internal/domain/config.go index b862411..73fe143 100644 --- a/internal/domain/config.go +++ b/internal/domain/config.go @@ -83,6 +83,10 @@ type SSHConfig struct { // 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. +// - RootDirectory is only used to build the path copied to the clipboard. +// Keeping it separate from PathPrefix lets the server expose a login root +// such as "~/ftp/" while uploads still target a relative directory such +// as "snapgo/". // - StrictHostKey and KnownHostsPath apply only to SFTP. Plain FTP has no // host-key mechanism and sends credentials and data without encryption. type FTPConfig struct { @@ -92,6 +96,7 @@ type FTPConfig struct { User string `json:"user"` AuthMethod string `json:"authMethod"` Password string `json:"password"` + RootDirectory string `json:"rootDirectory"` PathPrefix string `json:"pathPrefix"` StrictHostKey bool `json:"strictHostKey"` KnownHostsPath string `json:"knownHostsPath"` @@ -260,6 +265,7 @@ func DefaultAppConfig() AppConfig { Protocol: FTPProtocolFTP, Port: 21, AuthMethod: SSHAuthPassword, + RootDirectory: "/", PathPrefix: "snapgo/", ConnectTimeoutSecs: 10, StrictHostKey: false, @@ -371,6 +377,13 @@ func (c *AppConfig) Normalize() { if c.FTP.AuthMethod != SSHAuthPassword && c.FTP.AuthMethod != SSHAuthKey { c.FTP.AuthMethod = SSHAuthPassword } + if c.FTP.RootDirectory == "" { + if c.FTP.Protocol == FTPProtocolSFTP { + c.FTP.RootDirectory = "~/" + } else { + c.FTP.RootDirectory = "/" + } + } if c.FTP.PathPrefix == "" { c.FTP.PathPrefix = "snapgo/" } diff --git a/internal/domain/config_ftp_test.go b/internal/domain/config_ftp_test.go index c3ead50..746b066 100644 --- a/internal/domain/config_ftp_test.go +++ b/internal/domain/config_ftp_test.go @@ -13,6 +13,9 @@ func TestDefaultAppConfigIncludesFTPDefaults(t *testing.T) { if cfg.FTP.PathPrefix != "snapgo/" { t.Fatalf("expected default path prefix, got %q", cfg.FTP.PathPrefix) } + if cfg.FTP.RootDirectory != "/" { + t.Fatalf("expected default root directory, got %q", cfg.FTP.RootDirectory) + } if cfg.FTP.ConnectTimeoutSecs != 10 { t.Fatalf("expected 10 second timeout, got %d", cfg.FTP.ConnectTimeoutSecs) } @@ -31,6 +34,9 @@ func TestNormalizeUsesSFTPDefaultPort(t *testing.T) { if cfg.FTP.AuthMethod != SSHAuthPassword { t.Fatalf("expected password auth default, got %q", cfg.FTP.AuthMethod) } + if cfg.FTP.RootDirectory != "~/" { + t.Fatalf("expected SFTP home root directory, got %q", cfg.FTP.RootDirectory) + } } func TestIsFTPConfiguredRequiresProtocolHostAndUser(t *testing.T) {