src/web.ps1

class Options {
    [String[]] GetProperties() {
        return $this | Get-Member -MemberType Properties | Select-Object -ExpandProperty Name
    }
    [PSObject] SetProperties($Object) {
        $this.GetProperties() | ForEach-Object { $Object.$_ = $this.$_ }
        return $Object
    }
}
class FormOptions: Options {
    [Int] $Width = 960
    [Int] $Height = 700
    [Int] $FormBorderStyle = 3
    [Double] $Opacity = 1.0
    [Bool] $ControlBox = $True
    [Bool] $MaximizeBox = $False
    [Bool] $MinimizeBox = $False
}
class BrowserOptions: Options {
    [String] $Anchor = 'Left,Top,Right,Bottom'
    [PSObject] $Size = @{ Height = 700; Width = 960 }
    [Bool] $IsWebBrowserContextMenuEnabled = $False
}
function ConvertFrom-ByteArray {
    <#
    .SYNOPSIS
    Converts bytes to human-readable text
    #>

    [CmdletBinding()]
    Param(
        [Parameter(Mandatory = $True, Position = 0, ValueFromPipeline = $True)]
        [Array] $Data
    )
    Begin {
        function Invoke-Convert {
            Param(
                [Parameter(Position = 0)]
                $Data
            )
            if ($Data.Length -gt 0) {
                if ($Data -is [Byte] -or $Data[0] -is [Byte]) {
                    [System.Text.Encoding]::ASCII.GetString($Data)
                } else {
                    $Data
                }
            }
        }
        Invoke-Convert $Data
    }
    End {
        Invoke-Convert $Input
    }
}
function ConvertFrom-Html {
    <#
    .SYNOPSIS
    Convert HTML string into object.
    .EXAMPLE
    '<html><body><h1>hello</h1></body></html>' | ConvertFrom-Html
    #>

    [CmdletBinding()]
    Param(
        [Parameter(Mandatory = $True, Position = 0, ValueFromPipeline = $True)]
        [String] $Value
    )
    $Html = New-Object -ComObject 'HTMLFile'
    try {
        # This works in PowerShell with Office installed
        $Html.IHTMLDocument2_write($Value)
    } catch {
        # This works when Office is not installed
        $Content = [System.Text.Encoding]::Unicode.GetBytes($Value)
        $Html.Write($Content)
    }
    $Html
}
function ConvertFrom-QueryString {
    <#
    .SYNOPSIS
    Returns parsed query parameters
    #>

    [CmdletBinding()]
    [OutputType([Object[]])]
    [OutputType([String])]
    Param(
        [Parameter(Mandatory = $True, Position = 0, ValueFromPipeline = $True)]
        [String] $Query
    )
    Begin {
        Use-Web
    }
    Process {
        $Decoded = [System.Web.HttpUtility]::UrlDecode($Query)
        if ($Decoded -match '=') {
            $Decoded -split '&' | Invoke-Reduce {
                Param($Acc, $Item)
                $Key, $Value = $Item -split '='
                $Acc.$Key = $Value.Trim()
            } -InitialValue @{}
        } else {
            $Decoded
        }
    }
}
function ConvertTo-Iso8601 {
    <#
    .SYNOPSIS
    Convert value to date in ISO 8601 format
    .NOTES
    See https://www.iso.org/iso-8601-date-and-time-format.html
    #>

    [CmdletBinding()]
    Param(
        [Parameter(Position = 0, ValueFromPipeline = $True)]
        [String] $Value
    )
    Process {
        $Value | Get-Date -UFormat '+%Y-%m-%dT%H:%M:%S.000Z'
    }
}
function ConvertTo-JavaScript {
    <#
    .SYNOPSIS
    Convert PowerShell values to JavaScript strings. It is similar to ConvertTo-Json, but with broader support for Prelude types.
    .EXAMPLE
    $A = [Node]'A'
    $B = [Node]'B'
    $A, $B | ConvertTo-JavaScript
    .EXAMPLE
    @{ foo = 'bar' } | ConvertTo-JavaScript
    # returns {"foo":"bar"}
    .NOTES
    The ConvertTo-JavaScript cmdlet is not intended to be used as a data serializer as data is removed during conversion.
    #>

    [CmdletBinding()]
    [OutputType([System.String])]
    Param(
        [Parameter(Mandatory = $True, Position = 0, ValueFromPipeline = $True)]
        $Value
    )
    Begin {
        $CoordinateTemplate = {
            Param($Value)
            "{latitude: $($Value.Latitude), longitude: $($Value.Longitude), height: $($Value.Height), hemisphere: '$($Value.Hemisphere -join '')'}"
        }
        $MatrixTemplate = {
            Param($Value)
            $Rows = $Value.Values.Real |
                Invoke-Chunk -Size $Value.Size[1] |
                ForEach-Object { $_ -join ', ' } |
                ForEach-Object { "[$_]" }
            "[$($Rows -join ', ')]"
        }
        $NodeTemplate = {
            Param($Value)
            "{id: '$($Value.Id)', label: '$($Value.Label)'}"
        }
        $EdgeTemplate = {
            Param($Value)
            $Source = (& $NodeTemplate -Value $Value.Source)
            $Target = (& $NodeTemplate -Value $Value.Target)
            "{source: $Source, target: $Target}"
        }
        $GraphTemplate = {
            Param($Value)
            "{nodes: $($Value.Nodes | ConvertTo-JavaScript), edges: $($Value.Edges | ConvertTo-JavaScript)}"
        }
        $DefaultTemplate = {
            Param($Value)
            $Value | ConvertTo-Json -Compress
        }
        function Invoke-Convert {
            Param($Value)
            $Type = $Value.GetType().Name
            $Template = switch ($Type) {
                'Coordinate' { $CoordinateTemplate }
                'Matrix' { $MatrixTemplate }
                'Node' { $NodeTemplate }
                'DirectedEdge' { $EdgeTemplate }
                'Edge' { $EdgeTemplate }
                'Graph' { $GraphTemplate }
                Default { $DefaultTemplate }
            }
            & $Template -Value $Value
        }
        switch ($Value.Count) {
            1 {
                Invoke-Convert -Value $Value
            }
            { $_ -gt 1 } {
                "[$(($Value | ForEach-Object { Invoke-Convert -Value $_ }) -join ', ')]"
            }
        }
    }
    End {
        switch ($Input.Count) {
            1 {
                Invoke-Convert -Value $Input[0]
            }
            { $_ -gt 1 } {
                "[$(($Input | ForEach-Object { Invoke-Convert -Value $_ }) -join ', ')]"
            }
        }
    }
}
function ConvertTo-QueryString {
    <#
    .SYNOPSIS
    Returns URL-encoded query string
    #>

    [CmdletBinding()]
    [OutputType([String])]
    Param(
        [Parameter(Mandatory = $True, Position = 0, ValueFromPipeline = $True)]
        [PSObject] $InputObject,
        [Switch] $UrlEncode
    )
    Begin {
        Use-Web
    }
    Process {
        $Callback = {
            Param($Acc, $Item)
            $Key = $Item.Name
            $Value = $Item.Value
            "${Acc}$(if ($Acc -ne '') { '&' } else { '' })${Key}=${Value}"
        }
        $QueryString = $InputObject.GetEnumerator() | Sort-Object Name | Invoke-Reduce $Callback ''
        if (-not $QueryString) {
            $QueryString = ''
        }
        if ($UrlEncode) {
            Add-Type -AssemblyName System.Web
            [System.Web.HttpUtility]::UrlEncode($QueryString)
        } else {
            $QueryString
        }
    }
}
function Get-GithubOAuthToken {
    <#
    .SYNOPSIS
    Obtain OAuth token from https://api.github.com
    .DESCRIPTION
    This function enables obtaining an OAuth token from https://api.github.com
    Github provides multiple ways to obtain authentication tokens. This function implements the "device flow" method of authorizing an OAuth app.
    Before using this function, you must:
        1. Create an OAuth app on Github.com
        2. Record app "Client ID" (passed as -ClientID)
        3. Opt-in to "Device Authorization Flow" beta feature
    This function will attempt to open a browser and will require the user to login to his/her Github account to authorize access.
    The one-time device code will be copied to the clipboard for ease of use.
    > Note: For basic authentication scenarios, please use Invoke-WebRequestBasicAuth
    .EXAMPLE
    $Token = Get-GithubOAuthToken -ClientId $ClientId -Scope 'notifications'
    $Request = BasicAuth $Token -Uri 'https://api.github.com/notifications'
    #>

    [CmdletBinding()]
    Param(
        [Parameter(Mandatory = $True, Position = 0)]
        [String] $ClientId,
        [Parameter(Position = 1)]
        [String[]] $Scope
    )
    $ValidScopes = 'repo', 'repo:status', 'repo_deployment', 'public_repo', 'repo:invite', 'security_events', 'admin:repo_hook', 'write:repo_hook', 'read:repo_hook', 'admin:org', 'write:org', 'read:org', 'admin:public_key', 'write:public_key', 'read:public_key', 'admin:org_hook', 'gist', 'notifications', 'user', 'read:user', 'user:email', 'user:follow', 'delete_repo', 'write:discussion', 'read:discussion', 'write:packages', 'read:packages', 'delete:packages', 'admin:gpg_key', 'write:gpg_key', 'read:gpg_key', 'workflow'
    $IsValidScope = $Scope | Invoke-Reduce {
        Param($Acc, $Item)
        $Acc -and ($Item -in $ValidScopes)
    }
    if ($IsValidScope) {
        $DeviceRequestParameters = @{
            Post = $True
            Uri = 'https://github.com/login/device/code'
            Query = @{
                client_id = $ClientId
                scope = $Scope -join '%20'
            }
        }
        $DeviceData = Invoke-WebRequestBasicAuth @DeviceRequestParameters |
            ForEach-Object -MemberName 'Content' |
            ConvertFrom-ByteArray |
            ConvertFrom-QueryString
        $DeviceData | ConvertTo-Json | Write-Verbose
        $TokenRequestParameters = @{
            Post = $True
            Uri = 'https://github.com/login/oauth/access_token'
            Query = @{
                client_id = $ClientId
                device_code = $DeviceData['device_code']
                grant_type = 'urn:ietf:params:oauth:grant-type:device_code'
            }
        }
        $DeviceData['user_code'] | Write-Title -Green -PassThru | Set-Clipboard
        Start-Process $DeviceData['verification_uri']
        $Success = $False
        while (-not $Success) {
            Start-Sleep $DeviceData.interval
            $TokenData = Invoke-WebRequestBasicAuth @TokenRequestParameters |
                ForEach-Object -MemberName 'Content' |
                ConvertFrom-ByteArray |
                ConvertFrom-QueryString
            $Success = $TokenData['token_type'] -eq 'bearer'
        }
        $TokenData | ConvertTo-Json | Write-Verbose
        $TokenData['access_token']
    } else {
        "One or more scope values are invalid (Scopes: $(Join-StringsWithGrammar $Scope))" | Write-Error
    }
}
function Get-HtmlElement {
    <#
    .SYNOPSIS
    Helper utility for getting elements as an array from HTML formatted input using tagname, id, or class name
    .EXAMPLE
    $Html | Get-HtmlElement 'div'
    .EXAMPLE
    $Html | Get-HtmlElement '.some-class'
    .EXAMPLE
    $Html | Get-HtmlElement '#some-identifier'
    #>

    [CmdletBinding()]
    [OutputType([System.Object[]])]
    Param(
        [Parameter(ValueFromPipeline = $True)]
        $InputObject,
        [Parameter(Mandatory = $True, Position = 0)]
        [String] $Selector
    )
    Process {
        $InputType = $InputObject.GetType().Name
        $Html = if ($InputType -eq 'String') {
            $InputObject | ConvertFrom-Html
        } else {
            $InputObject
        }
        $Elements = @()
        switch -Regex ($Selector) {
            '^[.].*' {
                $ClassName = $_ | Remove-Character -At 0
                foreach ($Element in $Html.all) {
                    if ($Element.className -eq $ClassName) {
                        $Elements += $Element
                    }
                }
            }
            '^#.*' {
                $Id = $_ | Remove-Character -At 0
                foreach ($Element in $Html.getElementById($Id)) {
                    $Elements += $Element
                }
            }
            Default {
                foreach ($Element in $Html.all.tags($Selector)) {
                    $Elements += $Element
                }
            }
        }
        $Elements
    }
}
function Import-Html {
    <#
    .SYNOPSIS
    Import and parse an a local HTML file or web page.
    .EXAMPLE
    Import-Html example.com | ForEach-Object { $_.body.innerHTML }
    .EXAMPLE
    Import-Html .\bookmarks.html | ForEach-Object { $_.all.tags('a') } | Selelct-Object -ExpandProperty textContent
    #>

    [CmdletBinding()]
    Param(
        [Parameter(Mandatory = $True, Position = 0, ValueFromPipeline = $True)]
        [Alias('Uri')]
        [String] $Path
    )
    if (Test-Path $Path) {
        $Content = Get-Content -Path $Path -Raw
    } else {
        $Content = (Invoke-WebRequest -Uri $Path).Content
    }
    ConvertFrom-Html $Content
}
function Invoke-WebRequestBasicAuth {
    <#
    .SYNOPSIS
    Invoke-WebRequest wrapper that makes it easier to use basic authentication
    .PARAMETER TwoFactorAuthentication
    Name of API that requires 2FA. Use 'none' when 2FA is not required.
    Possible values:
        - 'Github'
        - 'none' [Default]
    .PARAMETER Data
    Data (Body) payload for HTTP request. Will only function with PUT and POST requests.
    ==> Analogous to the '-d' cURL flag
    ==> Data object will be converted to JSON string
    .PARAMETER WebRequestParameters
    Object for passing parameters to underlying invocation of Invoke-WebRequest
    .EXAMPLE
    # Authenticate a GET request with a token
    $Uri = 'https://api.github.com/notifications'
    $Query = @{ per_page = 100; page = 1 }
    $Request = Invoke-WebRequestBasicAuth $Token -Uri $Uri -Query $Query
    $Request.Content | ConvertFrom-Json | Format-Table -AutoSize
    .EXAMPLE
    # Use basic authentication with a username and password
    $Uri = 'https://api.github.com/notifications'
    $Query = @{ per_page = 100; page = 1 }
    $Request = Invoke-WebRequestBasicAuth $Username -Password $Token -Uri $Uri -Query $Query
    $Request.Content | ConvertFrom-Json | Format-Table -AutoSize
    .EXAMPLE
    # Execute a PUT request with a data payload
    $Uri = 'https://api.github.com/notifications'
    @{ last_read_at = '' } | BasicAuth $Token -Uri $Uri -Put
    .EXAMPLE
    $Uri = 'https://api.github.com/notifications'
    $Parameters = @{
        SkipCertificateChecks = $True
    }
    @{ last_read_at = '' } | BasicAuth $Token -Uri $Uri -Put -RequestParameters $Parameters
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Scope = 'Function')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingUsernameAndPasswordParams', '')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'Password')]
    [CmdletBinding(DefaultParameterSetName = 'token')]
    [Alias('basicauth')]
    Param(
        [Parameter(ParameterSetName = 'basic', Position = 0)]
        [String] $Username,
        [Parameter(ParameterSetName = 'basic')]
        [String] $Password,
        [Parameter(ParameterSetName = 'token', Position = 0)]
        [String] $Token,
        [Parameter(Mandatory = $True)]
        [UriBuilder] $Uri,
        [PSObject] $Query = @{},
        [Switch] $UrlEncode,
        [Switch] $ParseContent,
        [Alias('OTP')]
        [String] $TwoFactorAuthentication = 'none',
        [Switch] $Get,
        [Switch] $Post,
        [Switch] $Put,
        [Switch] $Delete,
        [Parameter(ValueFromPipeline = $True)]
        [PSObject] $Data = @{},
        [PSObject] $WebRequestParameters = @{}
    )
    Process {
        $Authorization = if ($Token.Length -gt 0) {
            "Bearer $Token"
        } else {
            $Credential = [Convert]::ToBase64String([System.Text.Encoding]::Ascii.GetBytes("${Username}:${Password}"))
            "Basic $Credential"
        }
        $Headers = @{
            Authorization = $Authorization
        }
        switch ($TwoFactorAuthentication) {
            'github' {
                'GitHub 2FA' | Write-Title -Green
                $Code = 'Code:' | Invoke-Input -Number -Indent 4
                $Headers.Accept = 'application/vnd.github.v3+json'
                $Headers['x-github-otp'] = $Code
            }
            Default {
                # Do nothing
            }
        }
        $Method = Find-FirstTrueVariable 'Get', 'Post', 'Put', 'Delete'
        $Uri.Query = $Query | ConvertTo-QueryString -UrlEncode:$UrlEncode
        $Parameters = @{
            Headers = $Headers
            Method = $Method
            Uri = $Uri.Uri
        }
        "==> Headers: $($Parameters.Headers | ConvertTo-Json)" | Write-Verbose
        "==> Method: $($Parameters.Method)" | Write-Verbose
        "==> URI: $($Parameters.Uri)" | Write-Verbose
        if ($Method -in 'Post', 'Put') {
            $Parameters.Body = $Data | ConvertTo-Json
            "==> Data: $($Data | ConvertTo-Json)" | Write-Verbose
        }
        $Request = Invoke-WebRequest @Parameters @WebRequestParameters
        if ($ParseContent) {
            $Request.Content | ConvertFrom-Json
        } else {
            $Request
        }
    }
}
function Out-Browser {
    <#
    .SYNOPSIS
    Display HTML content (string, file, or URI) in a web browser Windows form. Out-Browser will auto-detect content type.
    Returns [System.Windows.Forms.HtmlDocument] object
    .PARAMETER OnShown
    Function to be executed once form is shown. $Form and $Browser variables are available within function scope.
    .PARAMETER OnComplete
    Function to be executed whenever the Document within the browser is loaded. $Form and $Browser variables are available within function scope.
    .PARAMETER OnClose
    Function to be executed immediately before form is disposed. $Form and $Browser variables are available within function scope.
    .PARAMETER Default
    Use operating system default browser (i.e. Firefox, Chrome, etc...) instead of WebBrowser control.
    Note: OnShown, OnComplete, and OnClose will not be run when the Default parameter is used.
    .PARAMETER PassThru
    - Return WebBrowser document object when not using -Default parameter
    - Return process when using -Default parameter
    .EXAMPLE
    '<h1>Hello World</h1>' | Out-Browser
    .EXAMPLE
    'https://google.com' | Out-Browser
    .EXAMPLE
    '.\file.html' | Out-Browser
    .EXAMPLE
    $OnClose = {
        Param($Browser)
        $Browser.Document.GetElementsByTagName('h1').innerText | Write-Color -Green
    }
    '<h1 contenteditable="true">Type Here</h1>' | Out-Browser -OnClose $OnClose | Out-Null
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Scope = 'Function')]
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory = $True, Position = 0, ValueFromPipeline = $True)]
        [String] $Content,
        [FormOptions] $FormOptions = @{},
        [BrowserOptions] $BrowserOptions = @{},
        [ScriptBlock] $OnShown = {},
        [ScriptBlock] $OnComplete = {},
        [ScriptBlock] $OnClose = {},
        [Switch] $Default,
        [Switch] $PassThru
    )
    Begin {
        Use-Web -Browser
        $Form = $FormOptions.SetProperties((New-Object 'Windows.Forms.Form'))
        $Browser = $BrowserOptions.SetProperties((New-Object 'Windows.Forms.WebBrowser'))
        $Browser.Size = @{ Width = $Form.Width; Height = $Form.Height }
        $Form.Controls.Add($Browser)
        $ShownCallback = {
            '==> Form shown' | Write-Verbose
            $Form.BringToFront()
            & $OnShown -Form $Form -Browser $Browser
        }
        $CompletedCallback = {
            "==> Document load complete ($($_.Url))" | Write-Verbose
            & $OnComplete -Form $Form -Browser $Browser
        }
        $Form.Add_Shown($ShownCallback);
        $Browser.Add_DocumentCompleted($CompletedCallback)
    }
    Process {
        "==> Browser is $(if($Browser.IsOffline) { 'OFFLINE' } else { 'ONLINE' })" | Write-Verbose
        $IsFile = if (Test-Path $Content -IsValid) { Test-Path $Content } else { $False }
        $IsUri = ([Uri]$Content).IsAbsoluteUri
        if ($Default) {
            $FilePath = if ($IsFile -or $IsUri) {
                $Content
            } else {
                $TempRoot = if ($IsLinux) { '/tmp' } else { $Env:temp }
                $Path = Join-Path $TempRoot 'content.html'
                $Content | Set-Content -Path $Path
                $Path
            }
            $Process = Start-Process -FilePath $FilePath -PassThru
            if ($PassThru) {
                return $Process
            }
        } else {
            if ($IsFile) {
                "==> Opening ${Content}..." | Write-Verbose
                $Browser.Navigate("file:///$((Get-Item $Content).Fullname)")
            } elseif ($IsUri) {
                "==> Navigating to ${Content}..." | Write-Verbose
                $Browser.Navigate([Uri]$Content)
            } else {
                '==> Opening HTML in WebBrowser control...' | Write-Verbose
                $Browser.DocumentText = "$Content"
            }
            if ($Form.ShowDialog() -ne 'OK') {
                $Document = $Browser.Document
                '==> Browser closing...' | Write-Verbose
                & $OnClose -Form $Form -Browser $Browser
                $Form.Dispose()
                '==> Form disposed' | Write-Verbose
                if ($PassThru) {
                    return $Document
                }
            }
        }
    }
}
function Test-Url {
    <#
    .SYNOPSIS
    Test if a URL is accessible
    .PARAMETER Code
    Return status code as a string instead of boolean value
    .PARAMETER WebRequestParameters
    Object for passing parameters to underlying invocation of Invoke-WebRequest
    .EXAMPLE
    'https://google.com' | Test-Url
    #>

    [CmdletBinding()]
    [OutputType([Bool])]
    [OutputType([String])]
    Param(
        [Parameter(Mandatory = $True, Position = 0, ValueFromPipeline = $True)]
        [UriBuilder] $Value,
        [Switch] $Code,
        [PSObject] $WebRequestParameters = @{}
    )
    Process {
        $Response = try {
            Invoke-WebRequestBasicAuth -Uri $Value.Uri -WebRequestParameters $WebRequestParameters
        } catch {
            @{ StatusCode = '404' }
        }
        $StatusCode = $Response | Get-Property StatusCode
        switch ($StatusCode) {
            200 {
                if ($Code) { '200' } else { $True }
            }
            Default {
                if ($Code) { $StatusCode.ToString() } else { $False }
            }
        }
    }
}
function Use-Web {
    <#
    .SYNOPSIS
    Load related types for using web (with or without a web browser), if types are not already loaded.
    .PARAMETER Browser
    Whether or not to load WebBrowser type
    #>

    [CmdletBinding()]
    Param(
        [Switch] $Browser,
        [Switch] $PassThru
    )
    if (-not ('System.Web.HttpUtility' -as [Type])) {
        '==> Adding System.Web types' | Write-Verbose
        Add-Type -AssemblyName System.Web
    } else {
        '==> System.Web is already loaded' | Write-Verbose
    }
    if ($Browser) {
        if (-not ('System.Windows.Forms.WebBrowser' -as [Type])) {
            '==> Adding System.Windows.Forms types' | Write-Verbose
            Add-Type -AssemblyName System.Windows.Forms
        } else {
            '==> System.Windows.Forms is already loaded' | Write-Verbose
        }
    }
    if ($PassThru) {
        $True
    }
}