Public/Invoke-Download.ps1

<#
.SYNOPSIS
Downloads a file from a specified URI.
 
.DESCRIPTION
The Invoke-Download function downloads a file from a specified URI and saves it to the specified destination.
 
.PARAMETER Uri
The URI of the file to download. URL is accepted as an alias.
 
.PARAMETER Destination
The destination folder where the downloaded file will be saved. Default is the current working directory.
 
.PARAMETER FileName
The name of the downloaded file. If not provided, the function will attempt to extract the filename from the URI.
 
.PARAMETER UserAgent
The user agent string to use for the request. By default, it uses two user agent strings: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36' and 'Googlebot/2.1 (+http://www.google.com/bot.html)'. You can specify multiple user agent strings as an array.
 
.PARAMETER Headers
Additional headers to include in the request. Default is @{'accept' = '*/*'}, which is needed to trick some servers into serving a download, such as from FileZilla.
 
.PARAMETER TempPath
The temporary folder path to use for storing the downloaded file temporarily. Default is the user's temp folder.
 
.PARAMETER IgnoreDate
If specified, the function will not set the last modified date of the downloaded file.
 
.PARAMETER BlockFile
If specified, the downloaded file will be marked as downloaded from the internet.
 
.PARAMETER NoClobber
If specified, the function will not overwrite an existing file with the same name in the destination folder.
 
.PARAMETER NoProgress
If specified, the function will not display a progress bar during the download.
 
.PARAMETER PassThru
If specified, the function will return the downloaded file object.
 
.EXAMPLE
Invoke-Download -Uri 'https://example.com/file.txt' -Destination 'C:\Downloads'
 
This example downloads the file from the specified URI and saves it to the 'C:\Downloads' folder.
 
#>

function Invoke-Download {
    [CmdletBinding()]
    param(
        [Parameter(Position = 0, Mandatory = $true, ValueFromPipeline = $true)]
        [Alias('Url')]
        [ValidateNotNullOrEmpty()]
        [string]$Uri,
        
        [Parameter(Position = 1)]
        [ValidateNotNullOrEmpty()]
        [string]$Destination = $PWD.Path,
        
        [Parameter(Position = 2)]
        [string]$FileName,

        [string[]]$UserAgent = @($null, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36', 'Googlebot/2.1 (+http://www.google.com/bot.html)'),
        
        [hashtable]$Headers = @{accept = '*/*' },
        
        [string]$TempPath = [System.IO.Path]::GetTempPath(),
        
        [switch]$IgnoreDate,
        
        [switch]$BlockFile,
        
        [switch]$NoClobber,
        
        [switch]$NoProgress,
        
        [switch]$PassThru
    )    
    
    begin {
        # Required on Windows Powershell only
        if ($PSEdition -eq 'Desktop') {
            Add-Type -AssemblyName System.Net.Http
            Add-Type -AssemblyName System.Web
        }

        # Enable TLS 1.2 in addition to whatever is pre-configured
        [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12

        # Create one single client object for the pipeline
        $HttpClient = New-Object System.Net.Http.HttpClient

        foreach ($Header in $Headers.GetEnumerator()) {
            $HttpClient.DefaultRequestHeaders.Add($Header.Key, $Header.Value)
        }
    }

    process {

        $ResolveUriSplat = @{
            Uri       = $Uri
            UserAgent = $UserAgent
            Headers   = $Headers
        }
        $Properties = Resolve-Uri @ResolveUriSplat -ErrorAction Stop

        if ([string]::IsNullOrEmpty($FileName)) {
            if ([string]::IsNullOrEmpty($Properties.FileName)) {
                Write-Error "No filename found for $Uri"
                return
            }
            else {
                $FileName = $Properties.FileName
            }
        }

        $DestinationFilePath = Join-Path $Destination $FileName

        # Exit if -NoClobber specified and file exists.
        if ($NoClobber -and (Test-Path -LiteralPath $DestinationFilePath -PathType Leaf)) {
            Write-Error 'NoClobber switch specified and file already exists'
            return
        }

        foreach ($UserAgentString in $UserAgent) {
            $HttpClient.DefaultRequestHeaders.Remove('User-Agent') | Out-Null
            if ($UserAgentString) {
                Write-Verbose "$($MyInvocation.MyCommand): Using UserAgent '$UserAgentString'"
                $HttpClient.DefaultRequestHeaders.Add('User-Agent', $UserAgentString)
            }

            $ResponseStream = $HttpClient.GetStreamAsync($Uri)

            if ($ResponseStream.Result.CanRead) {
                break
            }
            else {
                continue
            }
        }

        if (!$ResponseStream.Result.CanRead) {
            throw "$($MyInvocation.MyCommand): $($ResponseStream.Exception.InnerException.Message)"
        }

        # Check TempPath exists and create it if not
        if (-not (Test-Path -LiteralPath $TempPath -PathType Container)) {
            Write-Verbose "$($MyInvocation.MyCommand): Temp folder '$TempPath' does not exist"
            try {
                New-Item -LiteralPath $Destination -ItemType Directory -Force | Out-Null
                Write-Verbose "$($MyInvocation.MyCommand): Created temp folder '$TempPath'"
            }
            catch {
                Write-Error "$($MyInvocation.MyCommand): Unable to create temp folder '$TempPath': $_"
                return
            }
        }
        
        # Generate temp file name
        $TempFileName = (New-Guid).ToString('N') + ".tmp"
        $TempFilePath = Join-Path $TempPath $TempFileName
        
        # Check Destination exists and create it if not
        if (-not (Test-Path -LiteralPath $Destination -PathType Container)) {
            Write-Verbose "$($MyInvocation.MyCommand): Output folder '$Destination' does not exist"
            try {
                New-Item -Path $Destination -ItemType Directory -Force | Out-Null
                Write-Verbose "$($MyInvocation.MyCommand): Created output folder '$Destination'"
            }
            catch {
                Write-Error "$($MyInvocation.MyCommand): Unable to create output folder '$Destination': $_"
                return
            }
        }
        
        # Open file stream
        try {
            $FileStream = [System.IO.File]::Create($TempFilePath)
        }
        catch {
            Write-Error "$($MyInvocation.MyCommand): Unable to create file '$TempFilePath': $_"
            return
        }
                
        if ($FileStream.CanWrite) {
            Write-Verbose "$($MyInvocation.MyCommand): Downloading to temp file '$TempFilePath'..."
        
            $Buffer = New-Object byte[] 64KB
            $BytesDownloaded = 0
            $ProgressIntervalMs = 250
            $ProgressTimer = (Get-Date).AddMilliseconds(-$ProgressIntervalMs)
        
            while ($true) {
                try {
                    # Read stream into buffer
                    $ReadBytes = $ResponseStream.Result.Read($Buffer, 0, $Buffer.Length)
        
                    # Track bytes downloaded and display progress bar if enabled and file size is known
                    $BytesDownloaded += $ReadBytes
                    if (!$NoProgress -and (Get-Date) -gt $ProgressTimer.AddMilliseconds($ProgressIntervalMs)) {
                        if ($Properties.FileSizeBytes) {
                            $PercentComplete = [System.Math]::Floor($BytesDownloaded / $Properties.FileSizeBytes * 100)
                            Write-Progress -Activity "Downloading $FileName" -Status "$BytesDownloaded of $($Properties.FileSizeBytes) bytes ($PercentComplete%)" -PercentComplete $PercentComplete
                        }
                        else {
                            Write-Progress -Activity "Downloading $FileName" -Status "$BytesDownloaded of ? bytes" -PercentComplete 0
                        }
                        $ProgressTimer = Get-Date
                    }
        
                    # If end of stream
                    if ($ReadBytes -eq 0) {
                        Write-Progress -Activity "Downloading $FileName" -Completed
                        $FileStream.Close()
                        $FileStream.Dispose()
                        try {
                            Write-Verbose "$($MyInvocation.MyCommand): Moving temp file to destination '$DestinationFilePath'"
                            $DownloadedFile = Move-Item -LiteralPath $TempFilePath -Destination $DestinationFilePath -Force -PassThru
                        }
                        catch {
                            Write-Error "$($MyInvocation.MyCommand): Error moving file from '$TempFilePath' to '$DestinationFilePath': $_"
                            return
                        }
                        if ($IsWindows) {
                            if ($BlockFile) {
                                Write-Verbose "$($MyInvocation.MyCommand): Marking file as downloaded from the internet"
                                Set-Content -LiteralPath $DownloadedFile -Stream 'Zone.Identifier' -Value "[ZoneTransfer]`nZoneId=3"
                            }
                            else {
                                Unblock-File -LiteralPath $DownloadedFile
                            }
                        }
                        if ($Properties.LastModified -and -not $IgnoreDate) {
                            Write-Verbose "$($MyInvocation.MyCommand): Setting Last Modified date"
                            $DownloadedFile.LastWriteTime = $Properties.LastModified
                        }
                        Write-Verbose "$($MyInvocation.MyCommand): Download complete!"
                        if ($PassThru) {
                            $DownloadedFile
                        }
                        break
                    }
                    $FileStream.Write($Buffer, 0, $ReadBytes)
                }
                catch {
                    Write-Error "$($MyInvocation.MyCommand): Error downloading file: $_"
                    Write-Progress -Activity "Downloading $FileName" -Completed
                    $FileStream.Close()
                    $FileStream.Dispose()
                    break
                }
            }
            
        }

    }

    end {
        $HttpClient.Dispose()
    }
}