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 }> = [
{ id: 'general', label: '通用设置' },
{ id: 's3', label: '对象存储' },
{ id: 'ftp', label: 'FTP / SFTP' },
{ id: 'ftp', label: '文件服务' },
{ id: 'ssh', label: '远程主机' },
{ id: 'llm', label: '智能识图' },
{ id: 'ocr', label: '文字提取' },
+35 -15
View File
@@ -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 tab file-transfer destination for its toolbar action. -->
<section v-if="props.tab === 'ftp'" class="card">
<h2>FTP / SFTP destination</h2>
<h2>文件服务</h2>
<div class="grid">
<label class="field">
<span>Protocol</span>
@@ -581,15 +592,20 @@ onMounted(load)
<input v-model="config.ftp.password" type="password" />
</label>
<label class="field full">
<span>Remote directory (under login root)</span>
<div class="prefixed-input">
<span class="input-prefix">{{ ftpPathRootLabel }}</span>
<input
v-model="ftpPathDisplay"
placeholder="snapgo/"
spellcheck="false"
/>
</div>
<span>根目录仅用于拼接复制路径</span>
<input
v-model.trim="config.ftp.rootDirectory"
:placeholder="config.ftp.protocol === 'sftp' ? '~/' : '/'"
spellcheck="false"
/>
</label>
<label class="field full">
<span>Remote directory登录目录下的上传目录</span>
<input
v-model="ftpPathDisplay"
placeholder="snapgo/"
spellcheck="false"
/>
</label>
<label class="field">
<span>Connect timeout (sec)</span>
@@ -615,6 +631,10 @@ onMounted(load)
</label>
</div>
<p class="hint">
复制路径示例<code>{{ ftpCopiedPathPreview }}</code>
</p>
<p v-if="config.ftp.protocol === 'ftp'" class="hint warn">
FTP sends the username, password, and screenshot without encryption.
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;
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"];
+13 -8
View File
@@ -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) {
+34 -5
View File
@@ -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)
}
})
}
}
+13
View File
@@ -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/"
}
+6
View File
@@ -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) {