<# .SYNOPSIS Transfers files to an SFTP server with filtering and logging. .DESCRIPTION Moves or copies files from a local folder to a remote SFTP destination. Supports regex/wildcard file filtering, recursive scanning, and secure credential storage. Uses the WinSCP .NET assembly for SFTP transport. .PARAMETER LocalPath Local source folder to scan for files. .PARAMETER RemotePath Remote SFTP destination folder (e.g. /uploads/incoming). .PARAMETER FileFilter Regex pattern to match filenames (e.g. '\.csv$' or '^report_\d{8}'). Default: '.*' (all files). .PARAMETER HostName SFTP server hostname or IP. .PARAMETER Port SFTP port. Default: 22. .PARAMETER UserName SFTP username. .PARAMETER SshHostKeyFingerprint SSH host key fingerprint for server verification. Use "ssh-rsa 2048 xx:xx:xx..." format, or pass "*" to accept any (NOT recommended for production). .PARAMETER Credential PSCredential object. If omitted, you'll be prompted interactively. .PARAMETER CredentialFile Path to a saved credential file (Export-Clixml). For scheduled/unattended runs. Create one with: Get-Credential | Export-Clixml -Path "C:\secure\sftp_cred.xml" .PARAMETER KeyFilePath Path to a private key file for key-based auth (optional). .PARAMETER Recurse Scan subdirectories in LocalPath. .PARAMETER RenamePattern Regex pattern to match in the filename for renaming before upload. Must be used together with -RenameReplacement. Supports capture groups (e.g. '(.+)\.csv$' with replacement '$1_processed.csv'). .PARAMETER RenameReplacement Replacement string for the -RenamePattern match. Supports regex capture groups ($1, ${1}, etc.). Use '$0' to reference the full match. .PARAMETER ArchivePath Move successfully uploaded files to this local folder instead of leaving them in place. The folder will be created if it does not exist. Cannot be combined with -DeleteAfterTransfer. .PARAMETER LocalRenamePattern Regex pattern to match in the filename when renaming the local file after a successful upload. Must be used with -LocalRenameReplacement. Works standalone (rename in place) or with -ArchivePath (rename while archiving). .PARAMETER LocalRenameReplacement Replacement string for -LocalRenamePattern. Supports regex capture groups ($1, ${1}, etc.). .PARAMETER DeleteAfterTransfer Delete local files after successful upload (move behavior). Cannot be combined with -ArchivePath. .PARAMETER DryRun Show what would be transferred without actually uploading. .PARAMETER LogFile Path to a log file. If omitted, logs to console only. .PARAMETER WinScpDllPath Path to WinSCPnet.dll. Default: looks in script directory, then common install paths. .EXAMPLE # Interactive - transfer all CSVs .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" ` -HostName "sftp.example.com" -UserName "uploader" -FileFilter '\.csv$' .EXAMPLE # Rename files by appending today's date before the extension .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" ` -HostName "sftp.example.com" -UserName "uploader" ` -RenamePattern '^(.+?)(\.[^.]+)$' -RenameReplacement "`$1_$(Get-Date -Format 'yyyyMMdd')`$2" .EXAMPLE # Add a prefix to every uploaded file .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" ` -HostName "sftp.example.com" -UserName "uploader" ` -RenamePattern '^' -RenameReplacement 'processed_' .EXAMPLE # Archive files to a local folder after upload .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" ` -HostName "sftp.example.com" -UserName "uploader" ` -ArchivePath "C:\exports\sent" .EXAMPLE # Archive and rename locally (append date) after upload .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" ` -HostName "sftp.example.com" -UserName "uploader" ` -ArchivePath "C:\exports\sent" ` -LocalRenamePattern '^(.+?)(\.[^.]+)$' -LocalRenameReplacement "`$1_sent`$2" .EXAMPLE # Rename local file in place after upload (no archive) .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" ` -HostName "sftp.example.com" -UserName "uploader" ` -LocalRenamePattern '^' -LocalRenameReplacement 'done_' .EXAMPLE # Unattended with saved credentials and move behavior .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" ` -HostName "sftp.example.com" -UserName "uploader" ` -CredentialFile "C:\secure\sftp_cred.xml" ` -FileFilter '^report_\d{8}' -DeleteAfterTransfer -LogFile "C:\logs\sftp.log" .EXAMPLE # Dry run to preview what would transfer .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" ` -HostName "sftp.example.com" -UserName "uploader" -DryRun .EXAMPLE # Key-based auth, recursive scan .\Send-FilesToSftp.ps1 -LocalPath "C:\data" -RemotePath "/archive" ` -HostName "sftp.example.com" -UserName "svcaccount" ` -KeyFilePath "C:\keys\id_rsa.ppk" -Recurse #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [ValidateScript({ Test-Path $_ -PathType Container })] [string]$LocalPath, [Parameter(Mandatory)] [string]$RemotePath, [string]$FileFilter = '.*', [Parameter(Mandatory)] [string]$HostName, [int]$Port = 22, [Parameter(Mandatory)] [string]$UserName, [string]$SshHostKeyFingerprint = $null, [PSCredential]$Credential, [string]$CredentialFile, [string]$KeyFilePath, [string]$RenamePattern, [string]$RenameReplacement, [string]$ArchivePath, [string]$LocalRenamePattern, [string]$LocalRenameReplacement, [switch]$Recurse, [switch]$DeleteAfterTransfer, [switch]$DryRun, [string]$LogFile, [string]$WinScpDllPath ) # ── Logging ────────────────────────────────────────────────────────────────── function Write-Log { param( [string]$Message, [ValidateSet('INFO','WARN','ERROR','SUCCESS')] [string]$Level = 'INFO' ) $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $entry = "[$timestamp] [$Level] $Message" switch ($Level) { 'ERROR' { Write-Host $entry -ForegroundColor Red } 'WARN' { Write-Host $entry -ForegroundColor Yellow } 'SUCCESS' { Write-Host $entry -ForegroundColor Green } default { Write-Host $entry } } if ($LogFile) { $entry | Out-File -FilePath $LogFile -Append -Encoding utf8 } } # ── Locate WinSCP .NET Assembly ────────────────────────────────────────────── function Find-WinScpDll { $searchPaths = @( $WinScpDllPath (Join-Path $PSScriptRoot 'WinSCPnet.dll') (Join-Path $PSScriptRoot 'lib\WinSCPnet.dll') 'C:\Program Files (x86)\WinSCP\WinSCPnet.dll' 'C:\Program Files\WinSCP\WinSCPnet.dll' ) | Where-Object { $_ } foreach ($path in $searchPaths) { if (Test-Path $path) { return $path } } # Try NuGet package in common locations $nugetPath = Get-ChildItem -Path "$env:USERPROFILE\.nuget\packages\winscp" -Filter 'WinSCPnet.dll' -Recurse -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($nugetPath) { return $nugetPath.FullName } return $null } # ── Main ───────────────────────────────────────────────────────────────────── try { # ── Validate parameters ────────────────────────────────────────────── if (($RenamePattern -and -not $RenameReplacement) -or ($RenameReplacement -and -not $RenamePattern)) { Write-Log "-RenamePattern and -RenameReplacement must be used together." -Level ERROR exit 1 } if (($LocalRenamePattern -and -not $LocalRenameReplacement) -or ($LocalRenameReplacement -and -not $LocalRenamePattern)) { Write-Log "-LocalRenamePattern and -LocalRenameReplacement must be used together." -Level ERROR exit 1 } if ($ArchivePath -and $DeleteAfterTransfer) { Write-Log "-ArchivePath and -DeleteAfterTransfer cannot be used together." -Level ERROR exit 1 } # ── Create archive folder if needed ───────────────────────────────── if ($ArchivePath -and -not (Test-Path $ArchivePath)) { New-Item -ItemType Directory -Path $ArchivePath -Force | Out-Null Write-Log "Created archive folder: $ArchivePath" } Write-Log "═══ SFTP Transfer Starting ═══" Write-Log "Local path : $LocalPath" Write-Log "Remote path : $RemotePath" Write-Log "File filter : $FileFilter" Write-Log "Host : ${HostName}:${Port}" if ($RenamePattern) { Write-Log "Remote rename : '$RenamePattern' → '$RenameReplacement'" } if ($LocalRenamePattern) { Write-Log "Local rename : '$LocalRenamePattern' → '$LocalRenameReplacement'" } if ($ArchivePath) { Write-Log "Archive to : $ArchivePath" } if ($DryRun) { Write-Log "*** DRY RUN MODE - No files will be transferred ***" -Level WARN } # ── Find and load WinSCP ───────────────────────────────────────────── $dllPath = Find-WinScpDll if (-not $dllPath) { Write-Log "WinSCPnet.dll not found. Install options:" -Level ERROR Write-Log " 1) Install-Package WinSCP -Source nuget.org" -Level ERROR Write-Log " 2) Download from https://winscp.net/eng/downloads.php (.NET assembly)" -Level ERROR Write-Log " 3) Place WinSCPnet.dll in the same folder as this script" -Level ERROR exit 1 } Add-Type -Path $dllPath Write-Log "Loaded WinSCP from: $dllPath" # ── Resolve credentials ────────────────────────────────────────────── $password = $null if ($Credential) { $password = $Credential.GetNetworkCredential().Password } elseif ($CredentialFile) { if (-not (Test-Path $CredentialFile)) { Write-Log "Credential file not found: $CredentialFile" -Level ERROR exit 1 } $savedCred = Import-Clixml -Path $CredentialFile $password = $savedCred.GetNetworkCredential().Password Write-Log "Loaded credentials from file" } elseif (-not $KeyFilePath) { $promptCred = Get-Credential -UserName $UserName -Message "Enter SFTP password for $HostName" if (-not $promptCred) { Write-Log "No credentials provided. Aborting." -Level ERROR exit 1 } $password = $promptCred.GetNetworkCredential().Password } # ── Collect files ──────────────────────────────────────────────────── $gciParams = @{ Path = $LocalPath; File = $true } if ($Recurse) { $gciParams['Recurse'] = $true } $allFiles = Get-ChildItem @gciParams | Where-Object { $_.Name -match $FileFilter } if (-not $allFiles -or $allFiles.Count -eq 0) { Write-Log "No files matched filter '$FileFilter' in $LocalPath" -Level WARN exit 0 } Write-Log "Found $($allFiles.Count) file(s) matching filter" if ($DryRun) { Write-Log "Files that would be transferred:" -Level INFO foreach ($f in $allFiles) { $destName = if ($RenamePattern) { $f.Name -replace $RenamePattern, $RenameReplacement } else { $f.Name } $localFinalName = if ($LocalRenamePattern) { $f.Name -replace $LocalRenamePattern, $LocalRenameReplacement } else { $f.Name } $remoteDir = $RemotePath.TrimEnd('/') + ($f.DirectoryName.Substring($LocalPath.TrimEnd('\').Length) -replace '\\', '/') $remoteDest = "$remoteDir/$destName" $sizeKB = [math]::Round($f.Length / 1KB, 1) $remoteRenameNote = if ($RenamePattern -and $destName -ne $f.Name) { " [remote name: $destName]" } else { '' } Write-Log " UPLOAD : $($f.FullName) → $remoteDest (${sizeKB} KB)$remoteRenameNote" if ($DeleteAfterTransfer) { Write-Log " LOCAL : DELETE $($f.FullName)" } elseif ($ArchivePath) { $archiveDest = Join-Path $ArchivePath $localFinalName $localNote = if ($LocalRenamePattern -and $localFinalName -ne $f.Name) { " [renamed from $($f.Name)]" } else { '' } Write-Log " LOCAL : MOVE → $archiveDest$localNote" } elseif ($LocalRenamePattern -and $localFinalName -ne $f.Name) { $renameDest = Join-Path $f.DirectoryName $localFinalName Write-Log " LOCAL : RENAME → $renameDest" } } Write-Log "DRY RUN complete. $($allFiles.Count) file(s) would be transferred." -Level SUCCESS exit 0 } # ── Configure session ──────────────────────────────────────────────── $sessionOptions = New-Object WinSCP.SessionOptions -Property @{ Protocol = [WinSCP.Protocol]::Sftp HostName = $HostName PortNumber = $Port UserName = $UserName } if ($password) { $sessionOptions.Password = $password } if ($KeyFilePath) { if (-not (Test-Path $KeyFilePath)) { Write-Log "Private key file not found: $KeyFilePath" -Level ERROR exit 1 } $sessionOptions.SshPrivateKeyPath = $KeyFilePath Write-Log "Using key-based auth: $KeyFilePath" } if ($SshHostKeyFingerprint) { if ($SshHostKeyFingerprint -eq '*') { Write-Log "Accepting any SSH host key — NOT SAFE FOR PRODUCTION" -Level WARN $sessionOptions.GiveUpSecurityAndAcceptAnySshHostKey = $true } else { $sessionOptions.SshHostKeyFingerprint = $SshHostKeyFingerprint } } else { Write-Log "No SSH host key fingerprint provided — accepting any key" -Level WARN Write-Log " Get your fingerprint with: ssh-keyscan $HostName | ssh-keygen -lf -" -Level WARN $sessionOptions.GiveUpSecurityAndAcceptAnySshHostKey = $true } # ── Open session and transfer ──────────────────────────────────────── $session = New-Object WinSCP.Session $successCount = 0 $failCount = 0 try { $session.Open($sessionOptions) Write-Log "Connected to $HostName" -Level SUCCESS # Ensure remote directory exists if (-not $session.FileExists($RemotePath)) { Write-Log "Creating remote directory: $RemotePath" $session.CreateDirectory($RemotePath) } foreach ($file in $allFiles) { $relativePath = '' if ($Recurse) { $relativePath = $file.DirectoryName.Substring($LocalPath.TrimEnd('\').Length) -replace '\\', '/' } $destName = if ($RenamePattern) { $file.Name -replace $RenamePattern, $RenameReplacement } else { $file.Name } $targetDir = $RemotePath.TrimEnd('/') + $relativePath $targetPath = "$targetDir/$destName" if ($RenamePattern -and $destName -ne $file.Name) { Write-Log "Renaming: $($file.Name) → $destName" } try { # Ensure subdirectory exists on remote when recursing if ($Recurse -and $relativePath -and (-not $session.FileExists($targetDir))) { $session.CreateDirectory($targetDir) Write-Log "Created remote dir: $targetDir" } $transferOptions = New-Object WinSCP.TransferOptions $transferOptions.TransferMode = [WinSCP.TransferMode]::Binary # Always upload without auto-delete; handle local disposition ourselves $result = $session.PutFiles($file.FullName, $targetPath, $false, $transferOptions) $result.Check() $sizeKB = [math]::Round($file.Length / 1KB, 1) Write-Log "Transferred: $($file.Name) → $targetPath (${sizeKB} KB)" -Level SUCCESS $successCount++ # ── Local post-transfer disposition ────────────────────── $localFinalName = if ($LocalRenamePattern) { $file.Name -replace $LocalRenamePattern, $LocalRenameReplacement } else { $file.Name } if ($DeleteAfterTransfer) { Remove-Item -LiteralPath $file.FullName -Force Write-Log "Deleted local: $($file.FullName)" } elseif ($ArchivePath) { # Preserve subdirectory structure when recursing $archiveDir = if ($Recurse -and $relativePath) { $sub = $file.DirectoryName.Substring($LocalPath.TrimEnd('\').Length).TrimStart('\') Join-Path $ArchivePath $sub } else { $ArchivePath } if (-not (Test-Path $archiveDir)) { New-Item -ItemType Directory -Path $archiveDir -Force | Out-Null } $archiveDest = Join-Path $archiveDir $localFinalName Move-Item -LiteralPath $file.FullName -Destination $archiveDest -Force $archiveNote = if ($localFinalName -ne $file.Name) { " (renamed from $($file.Name))" } else { '' } Write-Log "Archived: $($file.FullName) → $archiveDest$archiveNote" } elseif ($LocalRenamePattern -and $localFinalName -ne $file.Name) { $renameDest = Join-Path $file.DirectoryName $localFinalName Rename-Item -LiteralPath $file.FullName -NewName $localFinalName -Force Write-Log "Renamed local: $($file.Name) → $localFinalName" } } catch { Write-Log "FAILED: $($file.Name) — $($_.Exception.Message)" -Level ERROR $failCount++ } } } finally { if ($session) { $session.Dispose() } } # ── Summary ────────────────────────────────────────────────────────── Write-Log "═══ Transfer Complete ═══" Write-Log " Succeeded : $successCount" if ($failCount -gt 0) { Write-Log " Failed : $failCount" -Level ERROR } if ($DeleteAfterTransfer) { Write-Log " Mode : MOVE (source files deleted on success)" } elseif ($ArchivePath) { Write-Log " Mode : ARCHIVE → $ArchivePath" } else { Write-Log " Mode : COPY (source files retained)" } if ($failCount -gt 0) { exit 1 } } catch { Write-Log "Fatal error: $($_.Exception.Message)" -Level ERROR Write-Log $_.ScriptStackTrace -Level ERROR exit 1 }