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-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 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
  .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

  #>

  [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 = @{}
  )
  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
    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.
  .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 = {}
  )
  Begin {
    Use-Web -Browser
    $Form = $FormOptions.SetProperties(([Windows.Forms.Form]::New()))
    $Browser = $BrowserOptions.SetProperties(([Windows.Forms.WebBrowser]::New()))
    $Browser.Size = @{ Width = $Form.Width; Height = $Form.Height }
    $Form.Controls.Add($Browser)
    $Form.Add_Shown({
      '==> Form shown' | Write-Verbose
      $Form.BringToFront()
      & $OnShown -Form $Form -Browser $Browser
    });
    $Browser.Add_DocumentCompleted({
      "==> Document load complete ($($_.Url))" | Write-Verbose
      & $OnComplete -Form $Form -Browser $Browser
    })
  }
  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 ($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 browser...' | 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
      return $Document
    }
  }
}
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
  }
}