using FluentFTP; using IceImportDAL.DTO.RemoteFileSources; using Microsoft.Extensions.Logging; namespace IceImportDAL.CustomCode.RemoteFileSources; // ConnectionType=0 (FTP). Uses FluentFTP's AsyncFtpClient — every public method here opens its own // connection and disconnects/disposes it before returning (via `using` + try/finally), matching // CLAUDE.md's IDbConnection/HttpClient discipline: never leak a socket past the call that opened it. // A file source per call (rather than a long-lived pooled client) is deliberate — RunScheduledImport // is a manually/externally-triggered, low-frequency operation (decision #2 in the plan defers any // recurring poller), so connection reuse across calls is not worth the added lifecycle complexity. public class FtpRemoteFileSource : IRemoteFileSource { private readonly ILogger _logger; public FtpRemoteFileSource(ILogger logger) => _logger = logger; public byte ConnectionType => 0; public async Task> ListFilesAsync(RemoteFileSourceConfigDTO config, CancellationToken ct) { var (host, port) = ParseHost(config.Host); using var client = new AsyncFtpClient(host, config.Username, config.Credential, port); try { await client.Connect(ct).ConfigureAwait(false); var listing = await client.GetListing(config.RemoteFolderPath, ct).ConfigureAwait(false); var results = new List(); foreach (var item in listing) { if (item.Type != FtpObjectType.File) continue; if (!WildcardPatternMatcher.IsMatch(item.Name, config.FileNamePattern)) continue; results.Add(new RemoteFileInfoDTO { FileName = item.Name, Size = item.Size, LastModifiedUtc = item.Modified, RemoteFullPath = item.FullName }); } return results; } finally { if (client.IsConnected) await client.Disconnect(ct).ConfigureAwait(false); } } public async Task DownloadAsync(RemoteFileSourceConfigDTO config, RemoteFileInfoDTO file, CancellationToken ct) { var (host, port) = ParseHost(config.Host); using var client = new AsyncFtpClient(host, config.Username, config.Credential, port); try { await client.Connect(ct).ConfigureAwait(false); var ms = new MemoryStream(); await client.DownloadStream(ms, file.RemoteFullPath, token: ct).ConfigureAwait(false); ms.Position = 0; return ms; } finally { if (client.IsConnected) await client.Disconnect(ct).ConfigureAwait(false); } } public async Task PostProcessAsync(RemoteFileSourceConfigDTO config, RemoteFileInfoDTO file, CancellationToken ct) { if (config.PostProcessAction == 0) return; // None var (host, port) = ParseHost(config.Host); using var client = new AsyncFtpClient(host, config.Username, config.Credential, port); try { await client.Connect(ct).ConfigureAwait(false); if (config.PostProcessAction == 1) // Delete { await client.DeleteFile(file.RemoteFullPath, ct).ConfigureAwait(false); } else if (config.PostProcessAction == 2) // Archive { if (string.IsNullOrWhiteSpace(config.ArchiveFolderPath)) { _logger.LogWarning( "IceImport FTP PostProcess: IceMapId {IceMapId} has PostProcessAction=Archive but no ArchiveFolderPath configured — leaving {File} in place.", config.IceMapId, file.FileName); return; } var destination = CombineRemotePath(config.ArchiveFolderPath, file.FileName); await client.MoveFile(file.RemoteFullPath, destination, FtpRemoteExists.Overwrite, ct) .ConfigureAwait(false); } } finally { if (client.IsConnected) await client.Disconnect(ct).ConfigureAwait(false); } } private static (string Host, int Port) ParseHost(string hostValue) { // HostVaultRef resolves to either "hostname" (default port 21) or "hostname:port". var parts = hostValue.Split(':', 2); if (parts.Length == 2 && int.TryParse(parts[1], out var port)) return (parts[0], port); return (hostValue, 21); } private static string CombineRemotePath(string folder, string fileName) => folder.EndsWith('/') ? $"{folder}{fileName}" : $"{folder}/{fileName}"; }