Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Has been cancelled
Go + Wails v3 + Vue 3 + SQLite + FreeRDP3 (purego) 183 tests, 76 source files, 9,910 lines of code Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
239 lines
5.5 KiB
Go
239 lines
5.5 KiB
Go
package sftp
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/pkg/sftp"
|
|
)
|
|
|
|
const MaxEditFileSize = 5 * 1024 * 1024 // 5MB
|
|
|
|
// FileEntry represents a file or directory in a remote filesystem.
|
|
type FileEntry struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Size int64 `json:"size"`
|
|
IsDir bool `json:"isDir"`
|
|
Permissions string `json:"permissions"`
|
|
ModTime string `json:"modTime"`
|
|
}
|
|
|
|
// SFTPService manages SFTP clients keyed by session ID.
|
|
type SFTPService struct {
|
|
clients map[string]*sftp.Client
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewSFTPService creates a new SFTPService.
|
|
func NewSFTPService() *SFTPService {
|
|
return &SFTPService{
|
|
clients: make(map[string]*sftp.Client),
|
|
}
|
|
}
|
|
|
|
// RegisterClient stores an SFTP client for a session.
|
|
func (s *SFTPService) RegisterClient(sessionID string, client *sftp.Client) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.clients[sessionID] = client
|
|
}
|
|
|
|
// RemoveClient removes and closes an SFTP client.
|
|
func (s *SFTPService) RemoveClient(sessionID string) {
|
|
s.mu.Lock()
|
|
client, ok := s.clients[sessionID]
|
|
if ok {
|
|
delete(s.clients, sessionID)
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
if ok && client != nil {
|
|
client.Close()
|
|
}
|
|
}
|
|
|
|
// getClient returns the SFTP client for a session or an error if not found.
|
|
func (s *SFTPService) getClient(sessionID string) (*sftp.Client, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
client, ok := s.clients[sessionID]
|
|
if !ok {
|
|
return nil, fmt.Errorf("no SFTP client for session %s", sessionID)
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
// SortEntries sorts file entries with directories first, then alphabetically by name.
|
|
func SortEntries(entries []FileEntry) {
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
if entries[i].IsDir != entries[j].IsDir {
|
|
return entries[i].IsDir
|
|
}
|
|
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
|
|
})
|
|
}
|
|
|
|
// fileInfoToEntry converts an os.FileInfo and its path into a FileEntry.
|
|
func fileInfoToEntry(info os.FileInfo, path string) FileEntry {
|
|
return FileEntry{
|
|
Name: info.Name(),
|
|
Path: path,
|
|
Size: info.Size(),
|
|
IsDir: info.IsDir(),
|
|
Permissions: info.Mode().Perm().String(),
|
|
ModTime: info.ModTime().UTC().Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
}
|
|
|
|
// List returns directory contents sorted (dirs first, then files alphabetically).
|
|
func (s *SFTPService) List(sessionID string, path string) ([]FileEntry, error) {
|
|
client, err := s.getClient(sessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
infos, err := client.ReadDir(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read directory %s: %w", path, err)
|
|
}
|
|
|
|
entries := make([]FileEntry, 0, len(infos))
|
|
for _, info := range infos {
|
|
entryPath := path
|
|
if !strings.HasSuffix(entryPath, "/") {
|
|
entryPath += "/"
|
|
}
|
|
entryPath += info.Name()
|
|
entries = append(entries, fileInfoToEntry(info, entryPath))
|
|
}
|
|
|
|
SortEntries(entries)
|
|
return entries, nil
|
|
}
|
|
|
|
// ReadFile reads a file (max 5MB). Returns content as string.
|
|
func (s *SFTPService) ReadFile(sessionID string, path string) (string, error) {
|
|
client, err := s.getClient(sessionID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
info, err := client.Stat(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("stat %s: %w", path, err)
|
|
}
|
|
|
|
if info.IsDir() {
|
|
return "", fmt.Errorf("%s is a directory", path)
|
|
}
|
|
|
|
if info.Size() > MaxEditFileSize {
|
|
return "", fmt.Errorf("file %s is %d bytes, exceeds max edit size of %d bytes", path, info.Size(), MaxEditFileSize)
|
|
}
|
|
|
|
f, err := client.Open(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("open %s: %w", path, err)
|
|
}
|
|
defer f.Close()
|
|
|
|
data, err := io.ReadAll(f)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read %s: %w", path, err)
|
|
}
|
|
|
|
return string(data), nil
|
|
}
|
|
|
|
// WriteFile writes content to a file.
|
|
func (s *SFTPService) WriteFile(sessionID string, path string, content string) error {
|
|
client, err := s.getClient(sessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
f, err := client.Create(path)
|
|
if err != nil {
|
|
return fmt.Errorf("create %s: %w", path, err)
|
|
}
|
|
defer f.Close()
|
|
|
|
if _, err := f.Write([]byte(content)); err != nil {
|
|
return fmt.Errorf("write %s: %w", path, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Mkdir creates a directory.
|
|
func (s *SFTPService) Mkdir(sessionID string, path string) error {
|
|
client, err := s.getClient(sessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := client.Mkdir(path); err != nil {
|
|
return fmt.Errorf("mkdir %s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Delete removes a file or empty directory.
|
|
func (s *SFTPService) Delete(sessionID string, path string) error {
|
|
client, err := s.getClient(sessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
info, err := client.Stat(path)
|
|
if err != nil {
|
|
return fmt.Errorf("stat %s: %w", path, err)
|
|
}
|
|
|
|
if info.IsDir() {
|
|
if err := client.RemoveDirectory(path); err != nil {
|
|
return fmt.Errorf("remove directory %s: %w", path, err)
|
|
}
|
|
} else {
|
|
if err := client.Remove(path); err != nil {
|
|
return fmt.Errorf("remove %s: %w", path, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Rename renames/moves a file.
|
|
func (s *SFTPService) Rename(sessionID string, oldPath, newPath string) error {
|
|
client, err := s.getClient(sessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := client.Rename(oldPath, newPath); err != nil {
|
|
return fmt.Errorf("rename %s to %s: %w", oldPath, newPath, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Stat returns info about a file/directory.
|
|
func (s *SFTPService) Stat(sessionID string, path string) (*FileEntry, error) {
|
|
client, err := s.getClient(sessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
info, err := client.Stat(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stat %s: %w", path, err)
|
|
}
|
|
|
|
entry := fileInfoToEntry(info, path)
|
|
return &entry, nil
|
|
}
|