Private
Public Access
1
0

新增:SFTP直连+连接池+autoConnect+文件服务器端口自动回退

- SFTP模块:连接/断开/文件CRUD/系统信息采集/base64二进制写入
- 连接池:多服务器同时在线,瞬间切换profile
- autoConnect:启动时自动连接所有非本地服务器
- 端口自动回退:listenWithFallback消除TOCTOU,解决端口冲突崩溃
- 文件服务器URL集中管理:file-server.ts消除8+处硬编码端口
- Sidebar设置面板:添加服务器/自动连接/自动刷新开关
- 修复:validateFilePath越界panic、正则预编译
- 修复:注释准确性(RemoveAll/端口8073/动态端口文档)
This commit is contained in:
2026-05-04 15:33:19 +08:00
parent 6eaaa56eb6
commit 6bee55b96f
41 changed files with 2620 additions and 458 deletions

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log"
"net"
"net/http"
"net/url"
"os"
@@ -15,6 +16,8 @@ import (
"time"
)
const DefaultFileServerPort = 2652
// 预编译正则表达式(避免每次调用重复编译)
var (
// CSS 相关
@@ -44,6 +47,7 @@ var (
// HTML 预览路径修复
locationPathRegex = regexp.MustCompile(`\blocation\.pathname\b`)
winDriveRegex = regexp.MustCompile(`^[A-Za-z]:`)
)
// HTML 属性正则缓存(避免 replaceHtmlTagAttribute 中重复编译)
@@ -80,7 +84,7 @@ func validateFilePath(rawPath string, logPrefix string) (string, error) {
filePath = filepath.Clean(filePath)
// 确保绝对路径Linux 以 / 开头Windows 以盘符开头)
if !filepath.IsAbs(filePath) && len(filePath) > 0 && filePath[0] != '/' && filePath[1] != ':' {
if !filepath.IsAbs(filePath) && len(filePath) > 0 && filePath[0] != '/' && (len(filePath) < 2 || filePath[1] != ':') {
filePath = "/" + filePath
}
@@ -103,40 +107,22 @@ var (
localFileServerOnce sync.Once
)
// StartLocalFileServer 启动本地文件服务器
// StartLocalFileServer 启动本地文件服务器(端口被占用时自动递增)
func StartLocalFileServer() (string, error) {
var initErr error
localFileServerOnce.Do(func() {
// 创建多路复用器
mux := http.NewServeMux()
// 注册 /localfs/ 路由
mux.HandleFunc("/localfs/", handleLocalFileRequest)
// 注册 HTML 预览专用路由
mux.HandleFunc("/localfs/html-preview", handleHtmlPreviewRequest)
// 创建服务器(固定端口)
server := &http.Server{
Addr: "localhost:8073",
Handler: mux,
addr, srv, err := listenWithFallback(DefaultFileServerPort, mux)
if err != nil {
initErr = fmt.Errorf("无法绑定端口(%d起始): %w", DefaultFileServerPort, err)
return
}
// 启动服务器
go func() {
log.Printf("[LocalFileServer] 正在启动...")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("[LocalFileServer] 启动失败: %v", err)
initErr = err
}
}()
localFileServer = &LocalFileServer{
server: server,
addr: "localhost:8073",
}
log.Printf("[LocalFileServer] 已启动,监听: %s", localFileServer.addr)
localFileServer = &LocalFileServer{server: srv, addr: addr}
log.Printf("[LocalFileServer] 已启动,监听: %s", addr)
})
if localFileServer == nil {
@@ -145,6 +131,33 @@ func StartLocalFileServer() (string, error) {
return localFileServer.addr, initErr
}
// listenWithFallback 从 basePort 开始尝试绑定,递增直到成功(最多试 10 次)
// 返回的 server 已在 goroutine 中 Serve(l),调用方无需再启动
func listenWithFallback(basePort int, handler http.Handler) (addr string, srv *http.Server, err error) {
for offset := 0; offset < 10; offset++ {
port := basePort + offset
addr = fmt.Sprintf("localhost:%d", port)
l, e := net.Listen("tcp", addr)
if e == nil {
srv = &http.Server{Handler: handler}
go func() {
if se := srv.Serve(l); se != http.ErrServerClosed {
log.Printf("[LocalFileServer] 异常退出: %v", se)
}
}()
return addr, srv, nil
}
log.Printf("[LocalFileServer] 端口 %d 被占用,尝试 %d...", port, port+1)
}
return "", nil, fmt.Errorf("端口 %d-%d 均不可用", basePort, basePort+9)
}
// GetLocalFileServerAddr 返回实际绑定的地址(含动态分配的端口)
func GetLocalFileServerAddr() string {
if localFileServer == nil { return fmt.Sprintf("http://localhost:%d", DefaultFileServerPort) }
return localFileServer.addr
}
// handleLocalFileRequest 处理本地文件请求
func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
// CORS 头:允许所有源访问(因为这是本地文件服务器)
@@ -177,7 +190,7 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
return
}
// 仅对非绝对路径添加前导 /Windows 盘符路径如 D:/ 已经是绝对路径,不能加 /
if !strings.HasPrefix(pathPart, "/") && !regexp.MustCompile(`^[A-Za-z]:`).MatchString(pathPart) {
if !strings.HasPrefix(pathPart, "/") && !winDriveRegex.MatchString(pathPart) {
pathPart = "/" + pathPart
}