2026-06-26 21:21:58 +02:00
|
|
|
//go:build linux || freebsd
|
|
|
|
|
// +build linux freebsd
|
|
|
|
|
|
|
|
|
|
package ftp
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net/url"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"codeberg.org/petrbalvin/goget/internal/core"
|
|
|
|
|
"codeberg.org/petrbalvin/goget/internal/output"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Protocol implements the core.Protocol interface for FTP/FTPS
|
|
|
|
|
type Protocol struct{}
|
|
|
|
|
|
|
|
|
|
// NewProtocol creates a new instance of FTP protocol
|
|
|
|
|
func NewProtocol() *Protocol {
|
|
|
|
|
return &Protocol{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Scheme returns the protocol scheme
|
|
|
|
|
func (p *Protocol) Scheme() string {
|
|
|
|
|
return "ftp"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CanHandle checks whether the protocol can handle the given URL
|
|
|
|
|
func (p *Protocol) CanHandle(u *url.URL) bool {
|
|
|
|
|
if u == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
scheme := strings.ToLower(u.Scheme)
|
|
|
|
|
return scheme == "ftp" || scheme == "ftps"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Capabilities returns the list of supported features
|
|
|
|
|
func (p *Protocol) Capabilities() []string {
|
|
|
|
|
return []string{
|
|
|
|
|
"download",
|
|
|
|
|
"resume",
|
|
|
|
|
"recursive",
|
|
|
|
|
"tls",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SupportsResume returns whether the protocol supports resume
|
|
|
|
|
func (p *Protocol) SupportsResume() bool {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SupportsCompression returns whether the protocol supports compression
|
|
|
|
|
func (p *Protocol) SupportsCompression() bool {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SupportsParallel returns whether the protocol supports parallel downloading
|
|
|
|
|
func (p *Protocol) SupportsParallel() bool {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SupportsRecursive returns whether the protocol supports recursive downloading
|
|
|
|
|
func (p *Protocol) SupportsRecursive() bool {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Download downloads a file or directory via FTP/FTPS
|
|
|
|
|
func (p *Protocol) Download(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) {
|
|
|
|
|
timeout := req.Timeout
|
|
|
|
|
if timeout <= 0 {
|
|
|
|
|
timeout = 30 * time.Second
|
|
|
|
|
}
|
|
|
|
|
client, err := NewClient(req.URL.String(), timeout)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, core.NewProtocolError(fmt.Sprintf("failed to create FTP client: %v", err), nil, core.SafeURL(req.URL))
|
|
|
|
|
}
|
2026-06-29 19:23:10 +02:00
|
|
|
if req.PinnedCertHash != "" {
|
|
|
|
|
client.SetPinnedCert(req.PinnedCertHash)
|
|
|
|
|
}
|
2026-06-26 21:21:58 +02:00
|
|
|
|
|
|
|
|
if err := client.Connect(); err != nil {
|
|
|
|
|
return nil, core.NewProtocolError(fmt.Sprintf("failed to connect: %v", err), nil, core.SafeURL(req.URL))
|
|
|
|
|
}
|
|
|
|
|
defer client.Close()
|
|
|
|
|
|
|
|
|
|
startTime := time.Now()
|
|
|
|
|
var bytesDownloaded int64 = 0
|
|
|
|
|
var chunksCount int
|
|
|
|
|
|
|
|
|
|
// Determine whether it is a file or directory
|
|
|
|
|
remotePath := req.URL.Path
|
|
|
|
|
if remotePath == "" {
|
|
|
|
|
remotePath = "/"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try to get file size - if it fails, it's probably a directory
|
|
|
|
|
fileSize, err := client.getFileSize(remotePath)
|
|
|
|
|
isFile := (err == nil)
|
|
|
|
|
|
|
|
|
|
if isFile {
|
|
|
|
|
// Downloading file
|
|
|
|
|
var outputFile string
|
|
|
|
|
if req.Output != "" {
|
|
|
|
|
outputFile = req.Output
|
|
|
|
|
} else {
|
|
|
|
|
outputFile = filepath.Base(remotePath)
|
|
|
|
|
if outputFile == "" || outputFile == "/" {
|
|
|
|
|
outputFile = "index.html"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Kontrola resume
|
|
|
|
|
var resumeOffset int64 = 0
|
|
|
|
|
if req.Resume && req.Output != "" {
|
|
|
|
|
if canResume, meta, _ := output.CanResume(req.Output, req.URL.String()); canResume && meta != nil {
|
|
|
|
|
resumeOffset = meta.Downloaded
|
|
|
|
|
if req.Verbose {
|
|
|
|
|
fmt.Fprintf(os.Stderr, "Resuming from offset %d\n", resumeOffset)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Progress callback
|
|
|
|
|
var progressCallback func(current, total int64)
|
|
|
|
|
if req.ProgressCallback != nil && fileSize > 0 {
|
|
|
|
|
totalSize := fileSize
|
|
|
|
|
startOffset := resumeOffset
|
|
|
|
|
progressCallback = func(current, _ int64) {
|
|
|
|
|
req.ProgressCallback(startOffset+current, totalSize, 0)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := client.DownloadFile(ctx, remotePath, outputFile, resumeOffset, progressCallback); err != nil {
|
|
|
|
|
return nil, core.NewProtocolError(fmt.Sprintf("download failed: %v", err), nil, core.SafeURL(req.URL))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get the actual size of the downloaded file
|
|
|
|
|
if info, err := os.Stat(outputFile); err == nil {
|
|
|
|
|
bytesDownloaded = info.Size()
|
|
|
|
|
}
|
|
|
|
|
chunksCount = 1
|
|
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
// Recursive download of directory
|
|
|
|
|
if !req.Recursive {
|
|
|
|
|
return nil, core.NewProtocolError("remote path is a directory, use --recursive to download", nil, core.SafeURL(req.URL))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
outputDir := req.Output
|
|
|
|
|
if outputDir == "" {
|
|
|
|
|
outputDir = filepath.Base(remotePath)
|
|
|
|
|
if outputDir == "" || outputDir == "/" {
|
|
|
|
|
outputDir = "download"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var progressCallback func(file string, current, total int64)
|
|
|
|
|
if req.Verbose {
|
|
|
|
|
progressCallback = func(file string, current, total int64) {
|
|
|
|
|
fmt.Fprintf(os.Stderr, "Downloading: %s\n", file)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Dispatch to the parallel implementation when the user
|
|
|
|
|
// requested worker-pool concurrency, otherwise fall back to the
|
|
|
|
|
// single-connection sequential implementation. The parallel path
|
|
|
|
|
// opens N control connections to avoid stepping on CWD state and
|
|
|
|
|
// to keep the existing client-side protocol serialisation.
|
|
|
|
|
if req.RecursiveParallel > 1 {
|
2026-06-29 19:23:10 +02:00
|
|
|
if err := DownloadDirectoryConcurrent(ctx, req.URL.String(), timeout, remotePath, outputDir, req.RecursiveParallel, req.PinnedCertHash, progressCallback); err != nil {
|
2026-06-26 21:21:58 +02:00
|
|
|
return nil, core.NewProtocolError(fmt.Sprintf("directory download failed: %v", err), nil, core.SafeURL(req.URL))
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if err := client.DownloadDirectory(ctx, remotePath, outputDir, progressCallback); err != nil {
|
|
|
|
|
return nil, core.NewProtocolError(fmt.Sprintf("directory download failed: %v", err), nil, core.SafeURL(req.URL))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate total size and file count
|
|
|
|
|
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if !info.IsDir() {
|
|
|
|
|
bytesDownloaded += info.Size()
|
|
|
|
|
chunksCount++
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
duration := time.Since(startTime)
|
|
|
|
|
|
|
|
|
|
return &core.DownloadResult{
|
|
|
|
|
BytesDownloaded: bytesDownloaded,
|
|
|
|
|
Duration: duration,
|
|
|
|
|
ChunksCount: chunksCount,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper function for reading from io.Reader
|
|
|
|
|
func readAll(reader io.Reader) ([]byte, error) {
|
|
|
|
|
var data []byte
|
|
|
|
|
buf := make([]byte, 32*1024)
|
|
|
|
|
for {
|
|
|
|
|
n, err := reader.Read(buf)
|
|
|
|
|
if n > 0 {
|
|
|
|
|
data = append(data, buf[:n]...)
|
|
|
|
|
}
|
|
|
|
|
if err == io.EOF {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return data, nil
|
|
|
|
|
}
|