342 lines
8.8 KiB
Go
342 lines
8.8 KiB
Go
// Package sftpclient provides an SFTP file browser and transfer capability
|
|
// over an established SSH connection. It wraps github.com/pkg/sftp to provide
|
|
// directory listing, upload, download, rename, delete, and mkdir operations
|
|
// that the frontend can call through the Wails API.
|
|
//
|
|
// The client is created per-session — each SSH session can have at most one
|
|
// SFTP subsystem active.
|
|
package sftpclient
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/pkg/sftp"
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
// FileEntry represents a single file or directory in a remote listing.
|
|
type FileEntry struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"` // full remote path
|
|
Size int64 `json:"size"`
|
|
IsDir bool `json:"isDir"`
|
|
Mode string `json:"mode"` // Unix permission string, e.g. "-rwxr-xr-x"
|
|
ModifiedAt int64 `json:"modifiedAt"` // Unix epoch milliseconds
|
|
Owner string `json:"owner"` // best-effort UID
|
|
}
|
|
|
|
// TransferProgress reports the state of a file transfer.
|
|
type TransferProgress struct {
|
|
Transferred int64 `json:"transferred"` // bytes transferred so far
|
|
Total int64 `json:"total"` // total bytes (0 if unknown)
|
|
Path string `json:"path"` // file being transferred
|
|
Done bool `json:"done"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// Client wraps an SFTP session.
|
|
type Client struct {
|
|
mu sync.Mutex
|
|
sftp *sftp.Client
|
|
closed bool
|
|
}
|
|
|
|
// NewClient creates an SFTP subsystem over an existing SSH connection.
|
|
func NewClient(sshConn *ssh.Client) (*Client, error) {
|
|
sc, err := sftp.NewClient(sshConn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sftpclient: failed to start SFTP subsystem: %w", err)
|
|
}
|
|
return &Client{sftp: sc}, nil
|
|
}
|
|
|
|
// Close shuts down the SFTP subsystem.
|
|
func (c *Client) Close() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if c.closed {
|
|
return nil
|
|
}
|
|
c.closed = true
|
|
if c.sftp != nil {
|
|
return c.sftp.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// List returns the contents of a remote directory.
|
|
func (c *Client) List(remotePath string) ([]FileEntry, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if c.closed || c.sftp == nil {
|
|
return nil, fmt.Errorf("sftpclient: client is closed")
|
|
}
|
|
|
|
entries, err := c.sftp.ReadDir(remotePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sftpclient: failed to list %s: %w", remotePath, err)
|
|
}
|
|
|
|
result := make([]FileEntry, 0, len(entries))
|
|
for _, e := range entries {
|
|
fe := FileEntry{
|
|
Name: e.Name(),
|
|
Path: path.Join(remotePath, e.Name()),
|
|
Size: e.Size(),
|
|
IsDir: e.IsDir(),
|
|
Mode: e.Mode().String(),
|
|
ModifiedAt: e.ModTime().UnixMilli(),
|
|
}
|
|
result = append(result, fe)
|
|
}
|
|
|
|
// Sort: directories first, then alphabetically.
|
|
sort.Slice(result, func(i, j int) bool {
|
|
if result[i].IsDir != result[j].IsDir {
|
|
return result[i].IsDir
|
|
}
|
|
return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name)
|
|
})
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// Stat returns information about a single remote path.
|
|
func (c *Client) Stat(remotePath string) (*FileEntry, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if c.closed || c.sftp == nil {
|
|
return nil, fmt.Errorf("sftpclient: client is closed")
|
|
}
|
|
|
|
info, err := c.sftp.Stat(remotePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sftpclient: stat failed for %s: %w", remotePath, err)
|
|
}
|
|
|
|
return &FileEntry{
|
|
Name: info.Name(),
|
|
Path: remotePath,
|
|
Size: info.Size(),
|
|
IsDir: info.IsDir(),
|
|
Mode: info.Mode().String(),
|
|
ModifiedAt: info.ModTime().UnixMilli(),
|
|
}, nil
|
|
}
|
|
|
|
// Mkdir creates a remote directory.
|
|
func (c *Client) Mkdir(remotePath string) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if c.closed || c.sftp == nil {
|
|
return fmt.Errorf("sftpclient: client is closed")
|
|
}
|
|
|
|
return c.sftp.Mkdir(remotePath)
|
|
}
|
|
|
|
// MkdirAll creates a remote directory and all parents.
|
|
func (c *Client) MkdirAll(remotePath string) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if c.closed || c.sftp == nil {
|
|
return fmt.Errorf("sftpclient: client is closed")
|
|
}
|
|
|
|
return c.sftp.MkdirAll(remotePath)
|
|
}
|
|
|
|
// Remove deletes a remote file or empty directory.
|
|
func (c *Client) Remove(remotePath string) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if c.closed || c.sftp == nil {
|
|
return fmt.Errorf("sftpclient: client is closed")
|
|
}
|
|
|
|
return c.sftp.Remove(remotePath)
|
|
}
|
|
|
|
// Rename moves or renames a remote file/directory.
|
|
func (c *Client) Rename(oldPath, newPath string) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if c.closed || c.sftp == nil {
|
|
return fmt.Errorf("sftpclient: client is closed")
|
|
}
|
|
|
|
return c.sftp.Rename(oldPath, newPath)
|
|
}
|
|
|
|
// Download copies a remote file to a local path. The onProgress callback
|
|
// is invoked periodically with transfer progress (may be nil).
|
|
func (c *Client) Download(remotePath, localPath string, onProgress func(TransferProgress)) error {
|
|
c.mu.Lock()
|
|
if c.closed || c.sftp == nil {
|
|
c.mu.Unlock()
|
|
return fmt.Errorf("sftpclient: client is closed")
|
|
}
|
|
|
|
// Get file size for progress reporting.
|
|
info, err := c.sftp.Stat(remotePath)
|
|
if err != nil {
|
|
c.mu.Unlock()
|
|
return fmt.Errorf("sftpclient: stat failed: %w", err)
|
|
}
|
|
totalSize := info.Size()
|
|
|
|
remote, err := c.sftp.Open(remotePath)
|
|
if err != nil {
|
|
c.mu.Unlock()
|
|
return fmt.Errorf("sftpclient: failed to open remote file: %w", err)
|
|
}
|
|
c.mu.Unlock()
|
|
defer remote.Close()
|
|
|
|
local, err := os.Create(localPath)
|
|
if err != nil {
|
|
return fmt.Errorf("sftpclient: failed to create local file: %w", err)
|
|
}
|
|
defer local.Close()
|
|
|
|
return c.copyWithProgress(remote, local, totalSize, remotePath, onProgress)
|
|
}
|
|
|
|
// Upload copies a local file to a remote path. The onProgress callback
|
|
// is invoked periodically with transfer progress (may be nil).
|
|
func (c *Client) Upload(localPath, remotePath string, onProgress func(TransferProgress)) error {
|
|
local, err := os.Open(localPath)
|
|
if err != nil {
|
|
return fmt.Errorf("sftpclient: failed to open local file: %w", err)
|
|
}
|
|
defer local.Close()
|
|
|
|
info, err := local.Stat()
|
|
if err != nil {
|
|
return fmt.Errorf("sftpclient: failed to stat local file: %w", err)
|
|
}
|
|
totalSize := info.Size()
|
|
|
|
c.mu.Lock()
|
|
if c.closed || c.sftp == nil {
|
|
c.mu.Unlock()
|
|
return fmt.Errorf("sftpclient: client is closed")
|
|
}
|
|
remote, err := c.sftp.Create(remotePath)
|
|
if err != nil {
|
|
c.mu.Unlock()
|
|
return fmt.Errorf("sftpclient: failed to create remote file: %w", err)
|
|
}
|
|
c.mu.Unlock()
|
|
defer remote.Close()
|
|
|
|
return c.copyWithProgress(local, remote, totalSize, remotePath, onProgress)
|
|
}
|
|
|
|
// UploadBytes writes in-memory data to a remote file path.
|
|
func (c *Client) UploadBytes(remotePath string, data []byte, onProgress func(TransferProgress)) error {
|
|
c.mu.Lock()
|
|
if c.closed || c.sftp == nil {
|
|
c.mu.Unlock()
|
|
return fmt.Errorf("sftpclient: client is closed")
|
|
}
|
|
remote, err := c.sftp.Create(remotePath)
|
|
if err != nil {
|
|
c.mu.Unlock()
|
|
return fmt.Errorf("sftpclient: failed to create remote file: %w", err)
|
|
}
|
|
c.mu.Unlock()
|
|
defer remote.Close()
|
|
|
|
reader := bytes.NewReader(data)
|
|
return c.copyWithProgress(reader, remote, int64(len(data)), remotePath, onProgress)
|
|
}
|
|
|
|
// CreateWriter creates or truncates a remote file and returns a streaming
|
|
// writer for callers that already manage progress and chunking.
|
|
func (c *Client) CreateWriter(remotePath string) (io.WriteCloser, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.closed || c.sftp == nil {
|
|
return nil, fmt.Errorf("sftpclient: client is closed")
|
|
}
|
|
remote, err := c.sftp.Create(remotePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sftpclient: failed to create remote file: %w", err)
|
|
}
|
|
return remote, nil
|
|
}
|
|
|
|
// Pwd returns the current working directory on the remote host.
|
|
func (c *Client) Pwd() (string, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if c.closed || c.sftp == nil {
|
|
return "", fmt.Errorf("sftpclient: client is closed")
|
|
}
|
|
|
|
return c.sftp.Getwd()
|
|
}
|
|
|
|
// copyWithProgress copies from src to dst, reporting progress periodically.
|
|
func (c *Client) copyWithProgress(src io.Reader, dst io.Writer, totalSize int64, filePath string, onProgress func(TransferProgress)) error {
|
|
buf := make([]byte, 32*1024) // 32 KB buffer
|
|
var transferred int64
|
|
lastReport := time.Now()
|
|
|
|
for {
|
|
n, err := src.Read(buf)
|
|
if n > 0 {
|
|
nw, wErr := dst.Write(buf[:n])
|
|
if wErr != nil {
|
|
return fmt.Errorf("sftpclient: write failed: %w", wErr)
|
|
}
|
|
transferred += int64(nw)
|
|
|
|
// Report progress at most every 100ms.
|
|
if onProgress != nil && time.Since(lastReport) > 100*time.Millisecond {
|
|
onProgress(TransferProgress{
|
|
Transferred: transferred,
|
|
Total: totalSize,
|
|
Path: filePath,
|
|
})
|
|
lastReport = time.Now()
|
|
}
|
|
}
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("sftpclient: read failed: %w", err)
|
|
}
|
|
}
|
|
|
|
// Final progress report.
|
|
if onProgress != nil {
|
|
onProgress(TransferProgress{
|
|
Transferred: transferred,
|
|
Total: totalSize,
|
|
Path: filePath,
|
|
Done: true,
|
|
})
|
|
}
|
|
|
|
return nil
|
|
}
|