//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)) } if req.PinnedCertHash != "" { client.SetPinnedCert(req.PinnedCertHash) } 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 { if err := DownloadDirectoryConcurrent(ctx, req.URL.String(), timeout, remotePath, outputDir, req.RecursiveParallel, req.PinnedCertHash, progressCallback); err != nil { 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 }