Private/Invoke-Download.ps1

function Invoke-Download {
    <#
    .NOTES
        Original code from: https://github.com/DanGough/PsDownload/
        Original author: Dan Gough
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0, Mandatory = $true, ValueFromPipeline = $true)]
        [Alias('URL')]
        [ValidateNotNullOrEmpty()]
        [System.String] $URI,

        [Parameter(Position = 1)]
        [ValidateNotNullOrEmpty()]
        [System.String] $Destination = $PWD.Path,

        [Parameter(Position = 2)]
        [System.String] $FileName,

        [System.String[]] $UserAgent = $script:resourceStrings.UserAgent.Download,

        [System.String] $TempPath = [System.IO.Path]::GetTempPath(),

        [System.Management.Automation.SwitchParameter] $IgnoreDate,
        [System.Management.Automation.SwitchParameter] $BlockFile,
        [System.Management.Automation.SwitchParameter] $NoClobber,
        [System.Management.Automation.SwitchParameter] $NoProgress,
        [System.Management.Automation.SwitchParameter] $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 -TypeName "System.Net.Http.HttpClient"
    }

    process {
        Write-Verbose -Message "$($MyInvocation.MyCommand): Requesting headers from URL '$URI'"

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

            # This sends a GET request but only retrieves the headers
            $ResponseHeader = $HttpClient.GetAsync($URI, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).Result
            if ($ResponseHeader.IsSuccessStatusCode) {
                # Exit the foreach if success
                break
            }
        }

        if ($ResponseHeader.IsSuccessStatusCode) {
            Write-Verbose -Message "$($MyInvocation.MyCommand): Successfully retrieved headers"

            if ($ResponseHeader.RequestMessage.RequestUri.AbsoluteUri -ne $URI) {
                Write-Verbose -Message "$($MyInvocation.MyCommand): URL '$URI' redirects to '$($ResponseHeader.RequestMessage.RequestUri.AbsoluteUri)'"
            }

            try {
                $FileSize = $null
                $FileSize = [System.Int32]$ResponseHeader.Content.Headers.GetValues('Content-Length')[0]
                $FileSizeReadable = switch ($FileSize) {
                    { $_ -gt 1TB } { '{0:n2} TB' -f ($_ / 1TB); break }
                    { $_ -gt 1GB } { '{0:n2} GB' -f ($_ / 1GB); break }
                    { $_ -gt 1MB } { '{0:n2} MB' -f ($_ / 1MB); break }
                    { $_ -gt 1KB } { '{0:n2} KB' -f ($_ / 1KB); break }
                    default { '{0} B' -f $_ }
                }
                Write-Verbose -Message "$($MyInvocation.MyCommand): File size: $FileSize bytes ($FileSizeReadable)"
            }
            catch {
                Write-Verbose -Message "$($MyInvocation.MyCommand): Unable to determine file size"
            }

            try {
                # Try to get the last modified date from the "Last-Modified" header, use error handling in case string is in invalid format
                $LastModified = $null
                $LastModified = [DateTime]::ParseExact($ResponseHeader.Content.Headers.GetValues('Last-Modified')[0], 'r', [System.Globalization.CultureInfo]::InvariantCulture)
                Write-Verbose -Message "$($MyInvocation.MyCommand): Last modified: $($LastModified.ToString())"
            }
            catch {
                Write-Verbose -Message "$($MyInvocation.MyCommand): Last-Modified header not found"
            }

            if ($FileName) {
                $FileName = $FileName.Trim()
                Write-Verbose -Message "$($MyInvocation.MyCommand): Will use supplied filename '$FileName'"
            }
            else {
                try {
                    # Get the file name from the "Content-Disposition" header if available
                    $ContentDispositionHeader = $null
                    $ContentDispositionHeader = $ResponseHeader.Content.Headers.GetValues('Content-Disposition')[0]
                    Write-Verbose -Message "$($MyInvocation.MyCommand): Content-Disposition header found: $ContentDispositionHeader"
                }
                catch {
                    Write-Verbose -Message "$($MyInvocation.MyCommand): Content-Disposition header not found"
                }

                if ($ContentDispositionHeader) {
                    $ContentDispositionRegEx = @'
^.*filename\*?\s*=\s*"?(?:UTF-8|iso-8859-1)?(?:'[^']*?')?([^";]+)
'@

                    if ($ContentDispositionHeader -match $ContentDispositionRegEx) {
                        # GetFileName ensures we are not getting a full path with slashes. UrlDecode will convert characters like %20 back to spaces.
                        $FileName = [System.IO.Path]::GetFileName([System.Web.HttpUtility]::UrlDecode($matches[1]))
                        # If any further invalid filename characters are found, convert them to spaces.
                        [System.IO.Path]::GetInvalidFileNameChars() | ForEach-Object { $FileName = $FileName.Replace($_, ' ') }
                        $FileName = $FileName.Trim()
                        Write-Verbose -Message "$($MyInvocation.MyCommand): Extracted filename '$FileName' from Content-Disposition header"
                    }
                    else {
                        Write-Verbose -Message "$($MyInvocation.MyCommand): Failed to extract filename from Content-Disposition header"
                    }
                }

                if ([System.String]::IsNullOrEmpty($FileName)) {
                    # If failed to parse Content-Disposition header or if it's not available, extract the file name from the absolute URL to capture any redirections.
                    # GetFileName ensures we are not getting a full path with slashes. UrlDecode will convert characters like %20 back to spaces.
                    # The URL is split with ? to ensure we can strip off any API parameters.
                    $FileName = [System.IO.Path]::GetFileName([System.Web.HttpUtility]::UrlDecode($ResponseHeader.RequestMessage.RequestUri.AbsoluteUri.Split('?')[0]))
                    [System.IO.Path]::GetInvalidFileNameChars() | ForEach-Object { $FileName = $FileName.Replace($_, ' ') }
                    $FileName = $FileName.Trim()
                    Write-Verbose -Message "$($MyInvocation.MyCommand): Extracted filename '$FileName' from absolute URL '$($ResponseHeader.RequestMessage.RequestUri.AbsoluteUri)'"
                }
            }
        }
        else {
            Write-Verbose -Message "$($MyInvocation.MyCommand): Failed to retrieve headers"
        }

        if ([System.String]::IsNullOrEmpty($FileName)) {
            # If still no filename set, extract the file name from the original URL.
            # GetFileName ensures we are not getting a full path with slashes. UrlDecode will convert characters like %20 back to spaces.
            # The URL is split with ? to ensure we can strip off any API parameters.
            $FileName = [System.IO.Path]::GetFileName([System.Web.HttpUtility]::UrlDecode($URI.Split('?')[0]))
            [System.IO.Path]::GetInvalidFileNameChars() | ForEach-Object { $FileName = $FileName.Replace($_, ' ') }
            $FileName = $FileName.Trim()
            Write-Verbose -Message "$($MyInvocation.MyCommand): Extracted filename '$FileName' from original URL '$URI'"
        }

        $DestinationFilePath = Join-Path -Path $Destination -ChildPath $FileName

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

        # Open the HTTP stream
        $ResponseStream = $HttpClient.GetStreamAsync($URI).Result
        if ($ResponseStream.CanRead) {

            # Check TempPath exists and create it if not
            if (-not (Test-Path -LiteralPath $TempPath -PathType Container)) {
                Write-Verbose -Message "$($MyInvocation.MyCommand): Temp folder '$TempPath' does not exist"

                try {
                    New-Item -Path $Destination -ItemType "Directory" -Force | Out-Null
                    Write-Verbose -Message "$($MyInvocation.MyCommand): Created temp folder '$TempPath'"
                }
                catch {
                    Write-Error -Message "$($MyInvocation.MyCommand): Unable to create temp folder '$TempPath': $($_.Exception.Message)"
                    return
                }
            }

            # Generate temp file name
            $TempFileName = (New-Guid).ToString('N') + ".tmp"
            $TempFilePath = Join-Path -Path $TempPath -ChildPath $TempFileName

            # Check Destination exists and create it if not
            if (-not (Test-Path -LiteralPath $Destination -PathType Container)) {
                try {
                    Write-Verbose -Message "$($MyInvocation.MyCommand): Output folder '$Destination' does not exist"
                    New-Item -Path $Destination -ItemType Directory -Force | Out-Null
                    Write-Verbose -Message "$($MyInvocation.MyCommand): Created output folder '$Destination'"
                }
                catch {
                    Write-Error "Unable to create output folder '$Destination': $($_.Exception.Message)"
                    return
                }
            }

            # Open file stream
            try {
                $FileStream = [System.IO.File]::Create($TempFilePath)
            }
            catch {
                Write-Error "Unable to create file '$TempFilePath': $($_.Exception.Message)"
                return
            }

            if ($FileStream.CanWrite) {
                Write-Verbose -Message "$($MyInvocation.MyCommand): Downloading to temp file '$TempFilePath'..."

                $Buffer = New-Object -TypeName byte[] 64KB
                $BytesDownloaded = 0
                $ProgressIntervalMs = 250
                $ProgressTimer = (Get-Date).AddMilliseconds(-$ProgressIntervalMs)

                while ($true) {
                    try {
                        # Read stream into buffer
                        $ReadBytes = $ResponseStream.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 ($FileSize) {
                                $PercentComplete = [System.Math]::Floor($BytesDownloaded / $FileSize * 100)
                                Write-Progress -Activity "Downloading $FileName" -Status "$BytesDownloaded of $FileSize 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 -Message "$($MyInvocation.MyCommand): Moving temp file to destination '$DestinationFilePath'"
                                $DownloadedFile = Move-Item -LiteralPath $TempFilePath -Destination $DestinationFilePath -Force -PassThru
                            }
                            catch {
                                Write-Error "Error moving file from '$TempFilePath' to '$DestinationFilePath': $($_.Exception.Message)"
                                return
                            }

                            if ($IsWindows) {
                                if ($BlockFile) {
                                    Write-Verbose -Message "$($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 ($LastModified -and -not $IgnoreDate) {
                                Write-Verbose -Message "$($MyInvocation.MyCommand): Setting Last Modified date"
                                $DownloadedFile.LastWriteTime = $LastModified
                            }
                            Write-Verbose -Message "$($MyInvocation.MyCommand): Download complete!"
                            if ($PassThru) {
                                $DownloadedFile
                            }
                            break
                        }
                        $FileStream.Write($Buffer, 0, $ReadBytes)
                    }
                    catch {
                        Write-Error -Message "$($MyInvocation.MyCommand): Error downloading file: $($_.Exception.Message)"
                        Write-Progress -Activity "Downloading $FileName" -Completed
                        $FileStream.Close()
                        $FileStream.Dispose()
                        break
                    }
                }
            }
        }
        else {
            Write-Error 'Failed to start download'
        }

        # Reset this to avoid reusing the same name when fed multiple URLs via the pipeline
        $FileName = $null
    }

    end {
        $HttpClient.Dispose()
    }
}