wraith/internal/sftp/service.go
Vantz Stockwell 8a096d7f7b
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Has been cancelled
Wraith v0.1.0 — Desktop SSH + RDP + SFTP Client
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>
2026-03-17 08:19:29 -04:00

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
}