feat: add ftp root path setting

This commit is contained in:
2026-07-13 13:20:31 +08:00
parent 50551de105
commit b69ef00013
7 changed files with 104 additions and 29 deletions
+1 -1
View File
@@ -68,7 +68,7 @@ const activeTab = ref<SettingsTab>('general')
const sidebarItems: Array<{ id: SettingsTab; label: string }> = [ const sidebarItems: Array<{ id: SettingsTab; label: string }> = [
{ id: 'general', label: '通用设置' }, { id: 'general', label: '通用设置' },
{ id: 's3', label: '对象存储' }, { id: 's3', label: '对象存储' },
{ id: 'ftp', label: 'FTP / SFTP' }, { id: 'ftp', label: '文件服务' },
{ id: 'ssh', label: '远程主机' }, { id: 'ssh', label: '远程主机' },
{ id: 'llm', label: '智能识图' }, { id: 'llm', label: '智能识图' },
{ id: 'ocr', label: '文字提取' }, { id: 'ocr', label: '文字提取' },
+30 -10
View File
@@ -69,6 +69,7 @@ interface FTPConfig {
user: string user: string
authMethod: 'password' | 'key' authMethod: 'password' | 'key'
password: string password: string
rootDirectory: string
pathPrefix: string pathPrefix: string
strictHostKey: boolean strictHostKey: boolean
knownHostsPath: string knownHostsPath: string
@@ -169,6 +170,7 @@ function defaultConfig(): AppConfig {
user: '', user: '',
authMethod: 'password', authMethod: 'password',
password: '', password: '',
rootDirectory: '/',
pathPrefix: 'snapgo/', pathPrefix: 'snapgo/',
strictHostKey: false, strictHostKey: false,
knownHostsPath: '', knownHostsPath: '',
@@ -271,8 +273,8 @@ const sshPathDisplay = computed({
}) })
// FTP and SFTP both upload relative to the authenticated login directory. // FTP and SFTP both upload relative to the authenticated login directory.
// The visible prefix communicates how that location will be copied: FTP uses // RootDirectory is display-only: it is prepended to the uploaded relative path
// its virtual root, while SFTP uses the SSH user's home directory. // when that path is copied, without changing where the file is uploaded.
const ftpPathDisplay = computed({ const ftpPathDisplay = computed({
get: () => config.value.ftp.pathPrefix, get: () => config.value.ftp.pathPrefix,
set: (raw: string) => { set: (raw: string) => {
@@ -283,17 +285,26 @@ const ftpPathDisplay = computed({
}, },
}) })
const ftpPathRootLabel = computed(() => const ftpCopiedPathPreview = computed(() => {
config.value.ftp.protocol === 'sftp' ? '~/' : '/' 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) { function changeFTPProtocol(event: Event) {
const next = (event.target as HTMLSelectElement).value as FTPProtocol const next = (event.target as HTMLSelectElement).value as FTPProtocol
const previous = config.value.ftp.protocol const previous = config.value.ftp.protocol
const previousDefault = previous === 'sftp' ? 22 : 21 const previousDefault = previous === 'sftp' ? 22 : 21
const previousRootDefault = previous === 'sftp' ? '~/' : '/'
if (config.value.ftp.port === previousDefault) { if (config.value.ftp.port === previousDefault) {
config.value.ftp.port = next === 'sftp' ? 22 : 21 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 config.value.ftp.protocol = next
} }
@@ -531,7 +542,7 @@ onMounted(load)
<!-- FTP/SFTP tab file-transfer destination for its toolbar action. --> <!-- FTP/SFTP tab file-transfer destination for its toolbar action. -->
<section v-if="props.tab === 'ftp'" class="card"> <section v-if="props.tab === 'ftp'" class="card">
<h2>FTP / SFTP destination</h2> <h2>文件服务</h2>
<div class="grid"> <div class="grid">
<label class="field"> <label class="field">
<span>Protocol</span> <span>Protocol</span>
@@ -581,15 +592,20 @@ onMounted(load)
<input v-model="config.ftp.password" type="password" /> <input v-model="config.ftp.password" type="password" />
</label> </label>
<label class="field full"> <label class="field full">
<span>Remote directory (under login root)</span> <span>根目录仅用于拼接复制路径</span>
<div class="prefixed-input"> <input
<span class="input-prefix">{{ ftpPathRootLabel }}</span> v-model.trim="config.ftp.rootDirectory"
:placeholder="config.ftp.protocol === 'sftp' ? '~/' : '/'"
spellcheck="false"
/>
</label>
<label class="field full">
<span>Remote directory登录目录下的上传目录</span>
<input <input
v-model="ftpPathDisplay" v-model="ftpPathDisplay"
placeholder="snapgo/" placeholder="snapgo/"
spellcheck="false" spellcheck="false"
/> />
</div>
</label> </label>
<label class="field"> <label class="field">
<span>Connect timeout (sec)</span> <span>Connect timeout (sec)</span>
@@ -615,6 +631,10 @@ onMounted(load)
</label> </label>
</div> </div>
<p class="hint">
复制路径示例<code>{{ ftpCopiedPathPreview }}</code>
</p>
<p v-if="config.ftp.protocol === 'ftp'" class="hint warn"> <p v-if="config.ftp.protocol === 'ftp'" class="hint warn">
FTP sends the username, password, and screenshot without encryption. FTP sends the username, password, and screenshot without encryption.
Prefer SFTP on networks you do not fully trust. SFTP is not FTPS. Prefer SFTP on networks you do not fully trust. SFTP is not FTPS.
+2
View File
@@ -186,6 +186,7 @@ export namespace domain {
user: string; user: string;
authMethod: string; authMethod: string;
password: string; password: string;
rootDirectory: string;
pathPrefix: string; pathPrefix: string;
strictHostKey: boolean; strictHostKey: boolean;
knownHostsPath: string; knownHostsPath: string;
@@ -203,6 +204,7 @@ export namespace domain {
this.user = source["user"]; this.user = source["user"];
this.authMethod = source["authMethod"]; this.authMethod = source["authMethod"];
this.password = source["password"]; this.password = source["password"];
this.rootDirectory = source["rootDirectory"];
this.pathPrefix = source["pathPrefix"]; this.pathPrefix = source["pathPrefix"];
this.strictHostKey = source["strictHostKey"]; this.strictHostKey = source["strictHostKey"];
this.knownHostsPath = source["knownHostsPath"]; this.knownHostsPath = source["knownHostsPath"];
+12 -7
View File
@@ -40,9 +40,9 @@ type CaptureAndFTPService struct {
Cfg domain.FTPConfig Cfg domain.FTPConfig
} }
// ExecuteWithBytes uploads previously captured PNG bytes and copies the exact // ExecuteWithBytes uploads previously captured PNG bytes and copies a display
// remote path semantics shown in Settings: FTP paths are rooted at "/", while // path built from the configured root directory plus the uploaded relative
// SFTP paths are relative to the authenticated user's home directory. // path. RootDirectory deliberately does not affect the upload destination.
func (s *CaptureAndFTPService) ExecuteWithBytes(ctx context.Context, pngBytes []byte) error { func (s *CaptureAndFTPService) ExecuteWithBytes(ctx context.Context, pngBytes []byte) error {
if s.Uploader == nil { if s.Uploader == nil {
s.notifyFailure("FTP/SFTP not configured") s.notifyFailure("FTP/SFTP not configured")
@@ -80,7 +80,7 @@ func (s *CaptureAndFTPService) ExecuteWithBytes(ctx context.Context, pngBytes []
return err return err
} }
clipText := ftpShareText(protocol, relPath) clipText := ftpShareText(protocol, s.Cfg.RootDirectory, relPath)
if s.Clipboard != nil { if s.Clipboard != nil {
if err := s.Clipboard.WriteText(clipText); err != nil { if err := s.Clipboard.WriteText(clipText); err != nil {
s.notifyFailure("clipboard write failed: " + err.Error()) s.notifyFailure("clipboard write failed: " + err.Error())
@@ -131,11 +131,16 @@ func normalizedFTPProtocol(protocol string) string {
return domain.FTPProtocolFTP return domain.FTPProtocolFTP
} }
func ftpShareText(protocol, relPath string) string { func ftpShareText(protocol, rootDirectory, relPath string) string {
rootDirectory = strings.TrimSpace(rootDirectory)
if rootDirectory == "" {
if protocol == domain.FTPProtocolSFTP { if protocol == domain.FTPProtocolSFTP {
return "~/" + relPath rootDirectory = "~/"
} else {
rootDirectory = "/"
} }
return "/" + relPath }
return strings.TrimRight(rootDirectory, "/") + "/" + strings.TrimLeft(relPath, "/")
} }
func (s *CaptureAndFTPService) notifyFailure(reason string) { func (s *CaptureAndFTPService) notifyFailure(reason string) {
+32 -3
View File
@@ -52,7 +52,7 @@ func TestCaptureAndFTPServiceUploadsAndCopiesFTPPath(t *testing.T) {
} }
} }
func TestCaptureAndFTPServiceCopiesSFTPHomePath(t *testing.T) { func TestCaptureAndFTPServiceCopiesConfiguredRootPath(t *testing.T) {
uploader := &fakeFTPUploader{} uploader := &fakeFTPUploader{}
clip := &fakeClipboard{} clip := &fakeClipboard{}
svc := &CaptureAndFTPService{ svc := &CaptureAndFTPService{
@@ -60,6 +60,7 @@ func TestCaptureAndFTPServiceCopiesSFTPHomePath(t *testing.T) {
Clipboard: clip, Clipboard: clip,
Cfg: domain.FTPConfig{ Cfg: domain.FTPConfig{
Protocol: domain.FTPProtocolSFTP, Protocol: domain.FTPProtocolSFTP,
RootDirectory: "~/ftp/",
PathPrefix: "images", PathPrefix: "images",
}, },
} }
@@ -67,8 +68,36 @@ func TestCaptureAndFTPServiceCopiesSFTPHomePath(t *testing.T) {
if err := svc.ExecuteWithBytes(context.Background(), []byte("png")); err != nil { if err := svc.ExecuteWithBytes(context.Background(), []byte("png")); err != nil {
t.Fatalf("upload SFTP screenshot: %v", err) t.Fatalf("upload SFTP screenshot: %v", err)
} }
if clip.text != "~/"+uploader.path { if clip.text != "~/ftp/"+uploader.path {
t.Fatalf("expected SFTP home path copied, got %q", clip.text) 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)
}
})
} }
} }
+13
View File
@@ -83,6 +83,10 @@ type SSHConfig struct {
// reuses the local ssh-agent and ~/.ssh/id_* discovery used by SSH/SCP. // reuses the local ssh-agent and ~/.ssh/id_* discovery used by SSH/SCP.
// - PathPrefix is relative to the account's login root. The application // - PathPrefix is relative to the account's login root. The application
// rejects absolute paths and traversal segments before uploading. // 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 // - StrictHostKey and KnownHostsPath apply only to SFTP. Plain FTP has no
// host-key mechanism and sends credentials and data without encryption. // host-key mechanism and sends credentials and data without encryption.
type FTPConfig struct { type FTPConfig struct {
@@ -92,6 +96,7 @@ type FTPConfig struct {
User string `json:"user"` User string `json:"user"`
AuthMethod string `json:"authMethod"` AuthMethod string `json:"authMethod"`
Password string `json:"password"` Password string `json:"password"`
RootDirectory string `json:"rootDirectory"`
PathPrefix string `json:"pathPrefix"` PathPrefix string `json:"pathPrefix"`
StrictHostKey bool `json:"strictHostKey"` StrictHostKey bool `json:"strictHostKey"`
KnownHostsPath string `json:"knownHostsPath"` KnownHostsPath string `json:"knownHostsPath"`
@@ -260,6 +265,7 @@ func DefaultAppConfig() AppConfig {
Protocol: FTPProtocolFTP, Protocol: FTPProtocolFTP,
Port: 21, Port: 21,
AuthMethod: SSHAuthPassword, AuthMethod: SSHAuthPassword,
RootDirectory: "/",
PathPrefix: "snapgo/", PathPrefix: "snapgo/",
ConnectTimeoutSecs: 10, ConnectTimeoutSecs: 10,
StrictHostKey: false, StrictHostKey: false,
@@ -371,6 +377,13 @@ func (c *AppConfig) Normalize() {
if c.FTP.AuthMethod != SSHAuthPassword && c.FTP.AuthMethod != SSHAuthKey { if c.FTP.AuthMethod != SSHAuthPassword && c.FTP.AuthMethod != SSHAuthKey {
c.FTP.AuthMethod = SSHAuthPassword c.FTP.AuthMethod = SSHAuthPassword
} }
if c.FTP.RootDirectory == "" {
if c.FTP.Protocol == FTPProtocolSFTP {
c.FTP.RootDirectory = "~/"
} else {
c.FTP.RootDirectory = "/"
}
}
if c.FTP.PathPrefix == "" { if c.FTP.PathPrefix == "" {
c.FTP.PathPrefix = "snapgo/" c.FTP.PathPrefix = "snapgo/"
} }
+6
View File
@@ -13,6 +13,9 @@ func TestDefaultAppConfigIncludesFTPDefaults(t *testing.T) {
if cfg.FTP.PathPrefix != "snapgo/" { if cfg.FTP.PathPrefix != "snapgo/" {
t.Fatalf("expected default path prefix, got %q", cfg.FTP.PathPrefix) 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 { if cfg.FTP.ConnectTimeoutSecs != 10 {
t.Fatalf("expected 10 second timeout, got %d", cfg.FTP.ConnectTimeoutSecs) 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 { if cfg.FTP.AuthMethod != SSHAuthPassword {
t.Fatalf("expected password auth default, got %q", cfg.FTP.AuthMethod) 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) { func TestIsFTPConfiguredRequiresProtocolHostAndUser(t *testing.T) {