modules/deploy/dsc/ext/Grani/GraniResource/DSCResources/Grani_Download/Grani_Download.psm1

#region Initialize

function Initialize
{
    # Load Assembly to use HttpClient
    try
    {
        Add-Type -AssemblyName System.Net.Http
    }
    catch
    {
    }

    # cache Location Variable
    # MSFT using this path, but this always clear when LCM runs. It means whenever you run "Get" you can't refer cache.
    # => need to change to persistence path to match with cache.
    # $script:cacheLocation = "$env:ProgramData\Microsoft\Windows\PowerShell\Configuration\BuiltinProvCache\Grani_Download"
    $script:cacheLocation = "$env:ProgramData\Microsoft\Windows\PowerShell\Configuration\CustomProvCache\Grani_Download"

    # Enum for Item Type
    Add-Type -TypeDefinition @"
        public enum GraniDonwloadItemTypeEx
        {
            FileInfo,
            DirectoryInfo,
            Other,
            NotExists
        }
"@

}

Initialize

#endregion

#region Message Definition

$debugMessage = DATA {
    ConvertFrom-StringData -StringData "
        AddRequestHeader = Adding Request Header. Key : '{0}', Value : '{1}'
        AddContentType = Adding ContentType : '{0}'
        AddKeepAliveToRequestHeader = Adding Keep-Alive as true to the Request Header.
        AddUserAgent = Adding UserAgent : '{0}'
        AddCredential = Adding Network Credential for Basic Authentication. UserName : '{0}'
        DownloadComplete = Download content complete.
        IsDestinationPathExist = Checking Destination Path is existing and Valid as a FileInfo
        IsDestinationPathAlreadyUpToDate = Matching FileHash to verify file is already exist/Up-To-Date or not.
        IsFileAlreadyUpToDate = CurrentFileHash : CachedFileHash -> {0} : {1}
        IsFileExists = File found from DestinationPath. Checking already up-to-date.
        ItemTypeWasFile = Destination Path found as File : '{0}'
        ItemTypeWasDirectory = Destination Path found but was Directory : '{0}'
        ItemTypeWasOther = Destination Path found but was neither File nor Directory: '{0}'
        ItemTypeWasNotExists = Destination Path not found : '{0}'
        SetCacheLocationPath = CacheLocation Value detected. Setting Custom CacheLocation Path : '{0}'
        TestUriConnection = Testing connection to the URI : {0}
        UpdateFileHashCache = Updating cache path '{1}' for current File hash SHA256 '{0}'.
        ValidateUri = Cast URI string '{0}' to System.Uri.
        ValidateFilePath = Check DestinationPath '{0}' is FileInfo and Parent Directory already exist.
        WriteStream = Start writing downloaded stream to File Path : '{0}'
    "

}

$verboseMessage = DATA {
    ConvertFrom-StringData -StringData "
        alreadyUpToDate = Current DestinationPath FileHash and Cache FileHash matched. File already Up-To-Date.
        DownloadStream = Status Code returns '{0}'. Start download stream from URI : '{1}'
        notUpToDate = Current DestinationPath FileHash and Cache FileHash not matched. Need to download latest file.
    "

}
$exceptionMessage = DATA {
    ConvertFrom-StringData -StringData "
        InvalidCastURI = Uri : '{0}' casted to [System.Uri] but was invalid string for URI. Make sure you have passed valid URI string.
        InvalidUriSchema = Specified URI is not valid: '{0}'. Only http|https|file are accepted.
        InvalidResponce = Status Code returns '{0}'. Stop download stream from URI : '{1}'
        DestinationPathAlreadyExistAsNotFile = Destination Path '{0}' already exist but not a file. Found itemType is {1}. Windows not allowed exist same name item.
    "

}

#endregion

#region *-TargetResource

function Get-TargetResource
{
    [OutputType([System.Collections.Hashtable])]
    [CmdletBinding()]
    param
    (
        [parameter(Mandatory = $true)]
        [System.String]$Uri,

        [parameter(Mandatory = $true)]
        [System.String]$DestinationPath,

        [parameter(Mandatory = $false)]
        [Microsoft.Management.Infrastructure.CimInstance[]]$Header = $null,

        [parameter(Mandatory = $false)]
        [System.String]$ContentType = "application/json",

        [parameter(Mandatory = $false)]
        [System.Management.Automation.PSCredential]$Credential = [PSCredential]::Empty,

        [parameter(Mandatory = $false)]
        [System.String]$UserAgent = [Microsoft.PowerShell.Commands.PSUserAgent]::InternetExplorer,

        [parameter(Mandatory = $false)]
        [System.Boolean]$AllowRedirect = $true,

        [parameter(Mandatory = $false)]
        [System.String]$CacheLocation = [string]::Empty
    )

    # Set Custom Cache Location
    if ($CacheLocation -ne [string]::Empty)
    {
        Write-Debug -Message ($debugMessage.SetCacheLocationPath -f $CacheLocation)
        $script:cacheLocation = $CacheLocation
    }

    # validate Uri can be parse to [URI] and Schema is http|https|file
    $validUri = ValidateUri -Uri $Uri

    # Initialize return values
    # Header and Credential will never return as TypeConversion problem
    $returnHash = 
    @{
        Uri = $Uri
        DestinationPath = $DestinationPath
        ContentType = $ContentType
        Credential = New-CimInstance -ClassName MSFT_Credential -Property @{Username=[string]$Credential.UserName; Password=[string]$null} -Namespace root/microsoft/windows/desiredstateconfiguration -ClientOnly
        UserAgent = $UserAgent
        AllowRedirect = $AllowRedirect
        Ensure = "Absent"
        CacheLocation = $CacheLocation
    }

    # Destination Path check
    Write-Debug -Message $debugMessage.IsDestinationPathExist
    $itemType = GetPathItemType -Path $DestinationPath

    $fileExists = $false
    switch ($itemType.ToString())
    {
        ([GraniDonwloadItemTypeEx]::FileInfo.ToString())
        {
            Write-Debug -Message ($debugMessage.ItemTypeWasFile -f $DestinationPath)
            $fileExists = $true
        }
        ([GraniDonwloadItemTypeEx]::DirectoryInfo.ToString())
        {
            Write-Debug -Message ($debugMessage.ItemTypeWasDirectory -f $DestinationPath)
        }
        ([GraniDonwloadItemTypeEx]::Other.ToString())
        {
            Write-Debug -Message ($debugMessage.ItemTypeWasOther -f $DestinationPath)
        }
        ([GraniDonwloadItemTypeEx]::NotExists.ToString())
        {
            Write-Debug -Message ($debugMessage.ItemTypeWasNotExists -f $DestinationPath)
        }
    }

    # Already Up-to-date Check
    Write-Debug -Message $debugMessage.IsDestinationPathAlreadyUpToDate
    if ($fileExists -eq $true)
    {
        Write-Debug -Message $debugMessage.IsFileExists
        $currentFileHash = GetFileHash -Path $DestinationPath
        $cachedFileHash = GetCache -DestinationPath $DestinationPath -Uri $validUri

        Write-Debug -Message ($debugMessage.IsFileAlreadyUpToDate -f $currentFileHash, $cachedFileHash)
        if ($currentFileHash -eq $cachedFileHash)
        {
            Write-Verbose -Message $verboseMessage.alreadyUpToDate
            $returnHash.Ensure = "Present"
        }
        else
        {
            Write-Verbose -Message $verboseMessage.notUpToDate
        }
    }

    return $returnHash
}


function Set-TargetResource
{
    [OutputType([Void])]
    [CmdletBinding()]
    param
    (
        [parameter(Mandatory = $true)]
        [System.String]$Uri,

        [parameter(Mandatory = $true)]
        [System.String]$DestinationPath,

        [parameter(Mandatory = $false)]
        [Microsoft.Management.Infrastructure.CimInstance[]]$Header = $null,

        [parameter(Mandatory = $false)]
        [System.String]$ContentType = "application/json",

        [parameter(Mandatory = $false)]
        [System.Management.Automation.PSCredential]$Credential = [PSCredential]::Empty,

        [parameter(Mandatory = $false)]
        [System.String]$UserAgent = [Microsoft.PowerShell.Commands.PSUserAgent]::InternetExplorer,

        [parameter(Mandatory = $false)]
        [System.Boolean]$AllowRedirect = $true,

        [parameter(Mandatory = $false)]
        [System.String]$CacheLocation = [string]::Empty
    )

    # Set Custom Cache Location
    if ($CacheLocation -ne [string]::Empty)
    {
        Write-Debug -Message ($debugMessage.SetCacheLocationPath -f $CacheLocation)
        $script:cacheLocation = $CacheLocation
    }

    # validate Uri can be parse to [URI] and Schema is http|https|file
    $validUri = ValidateUri -Uri $Uri

    # validate DestinationPath is valid
    ValidateFilePath -Path $DestinationPath

    # Convert CimInstance to HashTable
    $headerHashtable = ConvertKCimInstanceToHashtable -CimInstance $Header

    # Start Download
    Invoke-HttpClient -Uri $validUri -Path $DestinationPath -Header $headerHashtable -ContentType $ContentType -UserAgent $UserAgent -Credential $Credential -AllowRedirect $AllowRedirect

    # Update Cache for FileHash
    UpdateCache -DestinationPath $DestinationPath -Uri $validUri
}


function Test-TargetResource
{
    [OutputType([System.Boolean])]
    [CmdletBinding()]
    param
    (
        [parameter(Mandatory = $true)]
        [System.String]$Uri,

        [parameter(Mandatory = $true)]
        [System.String]$DestinationPath,

        [parameter(Mandatory = $false)]
        [Microsoft.Management.Infrastructure.CimInstance[]]$Header = $null,

        [parameter(Mandatory = $false)]
        [System.String]$ContentType = "application/json",

        [parameter(Mandatory = $false)]
        [System.Management.Automation.PSCredential]$Credential = [PSCredential]::Empty,

        [parameter(Mandatory = $false)]
        [System.String]$UserAgent = [Microsoft.PowerShell.Commands.PSUserAgent]::InternetExplorer,

        [parameter(Mandatory = $false)]
        [System.Boolean]$AllowRedirect = $true,

        [parameter(Mandatory = $false)]
        [System.String]$CacheLocation = [string]::Empty
    )

    $param = @{
        Uri = $Uri
        DestinationPath = $DestinationPath
        Header = $Header
        ContentType = $ContentPath
        Credential = $Credential
        UserAgent = $UserAgent
        AllowRedirect = $AllowRedirect
        CacheLocation = $CacheLocation
    }

    return (Get-TargetResource -DestinationPath $DestinationPath -Uri $Uri).Ensure -eq "Present"
}

#endregion

#region HttpClient Helper

function Invoke-HttpClient
{
    [OutputType([void])]
    [CmdletBinding()]
    param
    (
        [parameter(Mandatory = $true)]
        [uri]$Uri,

        [parameter(Mandatory = $true)]
        [string]$Path,

        [parameter(Mandatory = $false)]
        [System.Collections.Hashtable]$Header = @{},

        [parameter(Mandatory = $false)]
        [System.String]$ContentType = "application/json",

        [parameter(Mandatory = $false)]
        [System.Management.Automation.PSCredential]$Credential = [PSCredential]::Empty,

        [parameter(Mandatory = $false)]
        [System.String]$UserAgent = [Microsoft.PowerShell.Commands.PSUserAgent]::InternetExplorer,

        [parameter(Mandatory = $false)]
        [System.Boolean]$AllowRedirect = $true
    )

    begin
    {
        #region Initialize

        # Should support Timeout? : Default -> 1:40 min
        # Should support MaxResponseContentBufferSize? : Default -> 2147483647

        $httpClientHandler = New-Object System.Net.Http.HttpClientHandler
        $httpClientHandler.AllowAutoRedirect = $AllowRedirect
        
        $httpClient = New-Object System.Net.Http.HttpClient ($httpClientHandler)

        # Request Header
        if ($Header.Keys.Count -ne 0)
        {
            foreach ($item in $Header.GetEnumerator())
            {
                Write-Debug -Message ($debugMessage.AddRequestHeader -f $item.Key, $item.Value)
                $httpClient.DefaultRequestHeaders.Add($item.Key, $item.Value)
            }
            
        }

        # Request Header : Keep-Alive
        if (($httpClient.DefaultRequestHeaders.GetEnumerator() | where Key -eq "Keep-Alive" | measure).Count -eq 0)
        {
            Write-Debug -Message ($debugMessage.AddKeepAliveToRequestHeader)
            $httpClient.DefaultRequestHeaders.Add("Keep-Alive", "true")
        }

        # ContentType
        if ($ContentType -ne [string]::Empty)
        {
            Write-Debug -Message ($debugMessage.AddContentType -f $ContentType)
            $private:mediaType = New-Object System.Net.Http.Headers.MediaTypeWithQualityHeaderValue($ContentType)
            $httpClient.DefaultRequestHeaders.Accept.Add($mediaType)
        }

        # UserAgent
        if ($UserAgent -ne [string]::Empty)
        {
            Write-Debug -Message ($debugMessage.AddUserAgent -f $UserAgent)
            $httpClient.DefaultRequestHeaders.UserAgent.ParseAdd($UserAgent)
        }

        # Credential
        if ($Credential -ne [PSCredential]::Empty)
        {
            if ($Credential.GetNetworkCredential().Password -ne [string]::Empty)
            {
                # Credential on Handler does not work with Basic Authentication : http://stackoverflow.com/questions/25761214/why-would-my-rest-service-net-clients-send-every-request-without-authentication
                # $httpClientHandler.Credential = $Credential

                Write-Debug -Message ($debugMessage.AddCredential -f $Credential.UserName)
                $encoded = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes([String]::Format( "{0}:{1}", $Credential.UserName, $Credential.GetNetworkCredential().Password)));
                $httpClient.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue ("Basic", $encoded) # Basic Authentication Only
            }
        }

        #endregion
    }

    end
    {
        try
        {
            #region Test Connection

            Write-Debug -Message ($debugMessage.TestUriConnection -f $Uri.ToString())
            $res = $httpClient.GetAsync($Uri)
            $res.ConfigureAwait($false) > $null
            if ($res.Exception -ne $null){ throw $res.Exception }
            if ($res.Result.StatusCode -ne [System.Net.HttpStatusCode]::OK){ throw ($exceptionMessage.InvalidResponce -f $res.Result.StatusCode.value__, $Uri) }
            
            #endregion

            #region Execute Download

            Write-Verbose -Message ($verboseMessage.DownloadStream -f $res.Result.StatusCode.value__, $Uri)
            [System.Threading.Tasks.Task`1[System.IO.Stream]]$stream = $httpClient.GetStreamAsync($Uri)
            $stream.ConfigureAwait($false) > $null
            if ($stream.Exception -ne $null){ throw $stream.Exception }

            #endregion

            #region Write Stream to the file

            WriteStream -Path $Path -Stream $stream
            
            #endregion

            Write-Verbose -Message ($debugMessage.DownloadComplete)
        }
        catch [System.Exception]
        {
            throw $_
        }
        finally
        {
            if (($null -ne $res) -and ($res.IsCompleted -eq $true)){ $res.Dispose() }
            if ($null -ne $httpClient){ $httpClient.Dispose() }
            if ($null -ne $httpClientHandler){ $httpClientHandler.Dispose() }
        }
    }
}

function WriteStream
{
    [OutputType([void])]
    [CmdletBinding()]
    param
    (
        [parameter(Mandatory = $true)]
        [string]$Path,

        [parameter(Mandatory = $true)]
        [System.Threading.Tasks.Task`1[System.IO.Stream]]$Stream
    )

    try
    {
        # Write stream to the File
        Write-Debug -Message ($debugMessage.WriteStream -f $Path)
        $fileStream = [System.IO.File]::Create($Path)
        $Stream.Result.CopyTo($fileStream)
    }
    finally
    {
        if ($null -ne $fileStream){ $fileStream.Dispose() }
        if (($null -ne $Stream) -and ($Stream.IsCompleted -eq $true)){ $Stream.Dispose() }
    }
}

#endregion

#region Validation Helper

function ValidateUri
{
    [OutputType([uri])]
    [CmdletBinding()]
    param
    (
        [parameter(Mandatory = $true)]
        [string]$Uri
    )
    
    Write-Debug -Message ($debugMessage.ValidateUri -f $Uri)
    [uri]$result = $Uri -as [uri]
    if ($result.AbsolutePath -eq $null){ throw New-Object System.NullReferenceException ($exceptionMessage.InvalidCastURI -f $Uri)}
    if ($result.Scheme -notin "http", "https", "file")
    {
        $errorId = "UriValidationFailure";
        $errorMessage = $exceptionMessage.InvalidUriSchema -f ${Uri}
        ThrowInvalidDataException -ErrorId $errorId -ErrorMessage $errorMessage
    }
    return $result
}

function ValidateFilePath
{
    [OutputType([Void])]
    [CmdletBinding()]
    param
    (
        [parameter(Mandatory = $true)]
        [string]$Path
    )
    
    Write-Debug -Message ($debugMessage.ValidateFilePath -f $Path)
    $itemType = GetPathItemType -Path $Path
    switch ($itemType.ToString())
    {
        ([GraniDonwloadItemTypeEx]::FileInfo.ToString())
        {
            return;
        }
        ([GraniDonwloadItemTypeEx]::NotExists.ToString())
        {
            # Create Parent Directory check
            $parentPath = Split-Path $Path -Parent
            if (-not (Test-Path -Path $parentPath))
            {
                [System.IO.Directory]::CreateDirectory($parentPath) > $null
            }
        }
        Default
        {
            $errorId = "FileValidationFailure"
            $errorMessage = $exceptionMessage.DestinationPathAlreadyExistAsNotFile -f $Path, $itemType.ToString()
            ThrowInvalidDataException -ErrorId $errorId -ErrorMessage $errorMessage
        }
    }

}

#endregion

#region Cache Helper

function GetFileHash
{
    [OutputType([string])]
    [CmdletBinding()]
    param
    (
        [parameter(Mandatory = $true)]
        [string]$Path
    )

    return (Get-FileHash -Path $Path -Algorithm SHA256).Hash
}

function GetCacheKey
{
    [OutputType([string])]
    [CmdletBinding()]
    param
    (
        [parameter(Mandatory = $true)]
        [string]$DestinationPath,

        [parameter(Mandatory = $true)]
        [uri]$Uri
    )

    $key = [string]::Join("", @($DestinationPath, $Uri.AbsoluteUri.ToString())).GetHashCode().ToString()
    return $key
}

function GetCache
{
    [OutputType([string])]
    [CmdletBinding()]
    param
    (
        [parameter(Mandatory = $true)]
        [string]$DestinationPath,

        [parameter(Mandatory = $true)]
        [uri]$Uri
    )

    $cacheKey = GetCacheKey -DestinationPath $DestinationPath -Uri $Uri
    $path = Join-Path $script:cacheLocation $cacheKey
    
    # Test Cache Path is exist
    if (-not (Test-Path -Path $path)){ return [string]::Empty }

    # Get FileHash from Cache File
    $fileHash = (Import-CliXml -Path $path).FileHash
    return $fileHash    
}

function UpdateCache
{
    [OutputType([void])]
    [CmdletBinding()]
    param
    (
        [parameter(Mandatory = $true)]
        [string]$DestinationPath,

        [parameter(Mandatory = $true)]
        [uri]$Uri
    )

    $cacheKey = GetCacheKey -DestinationPath $DestinationPath -Uri $Uri
    $path = Join-Path $script:cacheLocation $cacheKey

    # create cacheLocaltion Directory
    if (-not (Test-Path -Path $script:cacheLocation))
    {
        [System.IO.Directory]::CreateDirectory($script:cacheLocation) > $null
    }

    # Create Cache Object
    $fileHash = GetFileHash -Path $DestinationPath
    $obj = NewXmlObject -DestinationPath $DestinationPath -Uri $Uri -FileHash $fileHash

    # export cache to CliXML
    Write-Debug ($debugMessage.UpdateFileHashCache -f $fileHash, $Path)
    $obj | Export-CliXml -Path $path -Force
}

function NewXmlObject
{
    [OutputType([PSCustomObject])]
    [CmdletBinding()]
    param
    (
        [parameter(Mandatory = $true)]
        [string]$DestinationPath,

        [parameter(Mandatory = $true)]
        [uri]$Uri,

        [parameter(Mandatory = $true)]
        [string]$FileHash
    )
    
    $obj = @{}
    $obj.FileHash = $FileHash
    $obj.WriteTime = [System.IO.File]::GetLastWriteTimeUtc($DestinationPath)
    $obj.Path = $DestinationPath
    $obj.Uri = $Uri.AbsoluteUri.ToString()
    return [PSCustomObject]$obj
}

#endregion

#region ItemType Helper

function GetPathItemType
{
    [OutputType([GraniDonwloadItemTypeEx])]
    [CmdletBinding()]
    param
    (
        [parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias("FullName", "LiteralPath", "PSPath")]
        [System.String]$Path = [string]::Empty
    )

    $type = [string]::Empty

    # Check type of the Path Item
    if (-not (Test-Path -Path $Path))
    {
        return [GraniDonwloadItemTypeEx]::NotExists
    }
    
    $pathItem = Get-Item -Path $path
    $pathItemType = $pathItem.GetType().FullName
    $type = switch ($pathItemType)
    {
        "System.IO.FileInfo"
        {
            [GraniDonwloadItemTypeEx]::FileInfo
        }
        "System.IO.DirectoryInfo"
        {
            [GraniDonwloadItemTypeEx]::DirectoryInfo
        }
        Default
        {
            [GraniDonwloadItemTypeEx]::Other
        }
    }

    return $type
}

#endregion

#region Converter from Microsoft.Management.Infrastructure.CimInstance[] (KeyValuePair) to HashTable

function ConvertKCimInstanceToHashtable
{
    [OutputType([hashtable[]])]
    [CmdletBinding()]
    param
    (
        [parameter(Mandatory = $false)]
        [AllowNull()]
        [Microsoft.Management.Infrastructure.CimInstance[]]$CimInstance
    )

    if ($null -eq $CimInstance)
    {
        return @{}
    }

    $hashtable = New-Object System.Collections.Generic.List[hashtable]
    foreach($item in $CimInstance.GetEnumerator())
    {
        $hashtable.Add(@{$item.Key = $item.Value})
    }

    return $hashtable
}

#endregion

#region Exception Helper

function ThrowInvalidDataException
{
    [OutputType([Void])]
    [CmdletBinding()]
    param
    (
        [parameter(Mandatory = $true)]
        [System.String]$ErrorId,

        [parameter(Mandatory = $true)]
        [System.String]$ErrorMessage
    )
    
    $errorCategory = [System.Management.Automation.ErrorCategory]::InvalidData
    $exception = New-Object System.InvalidOperationException $ErrorMessage 
    $errorRecord = New-Object System.Management.Automation.ErrorRecord $exception, $ErrorId, $errorCategory, $null
    throw $errorRecord
}

#endregion

Export-ModuleMember -Function *-TargetResource