src/web.ps1

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '', Scope = 'Function', Target = 'Register-GitlabRunner')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', '', Scope = 'Function', Target = 'Invoke-WebRequestBasicAuth')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingUsernameAndPasswordParams', '', Scope = 'Function', Target = 'Invoke-WebRequestBasicAuth')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Scope = 'Function', Target = 'Add-Metadata')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Scope = 'Function', Target = 'Add-Metadata')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Scope = 'Function', Target = 'Invoke-WebRequestBasicAuth')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Scope = 'Function', Target = 'Out-Browser')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('VariablePascalCase', '', Scope = 'Function', Target = 'Register-GitlabRunner')]
Param()

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 Add-Metadata {
    <#
    .SYNOPSIS
    Identify certain elements and wrap them in semantic HTML tags.
    .EXAMPLE
    'My email is foo@bar.com' | ConvertTo-Html
    #>

    [CmdletBinding()]
    [OutputType([System.String])]
    Param(
        [Parameter(Mandatory = $True, Position = 0, ValueFromPipeline = $True)]
        [String] $Text,
        [String[]] $Keyword,
        [Hashtable] $Abbreviations,
        [Switch] $Microformat,
        [ValidateSet('all', 'date', 'duration', 'email', 'url', 'ip')]
        [String[]] $Disable
    )
    Begin {
        $Custom = [Regex](($Keyword | ForEach-Object { "(\b${_}\b)" }) -join '|' )
        $Date = [Regex](New-RegexString -Date)
        $Duration = [RegEx](New-RegexString -Duration)
        $Email = [Regex](New-RegexString -Email)
        $Url = [Regex](New-RegexString -Url)
        $IpAdress = [Regex](New-RegexString -IPv4 -IPv6)
        $Attributes = @{
            Custom = 'itemprop="thing"'
            Date = 'itemscope itemtype="https://schema.org/DateTime" class="dt-event"'
            Duration = 'itemscope itemprop="event" itemtype="https://schema.org/Event" class="duration dt-event"'
            Email = 'itemscope itemprop="email" itemtype="https://schema.org/email" class="u-email"'
            End = 'itemscope itemprop="endTime" itemtype="https://schema.org/Time" class="dt-end"'
            Start = 'itemscope itemprop="startTime" itemtype="https://schema.org/Time" class="dt-start"'
            Url = 'itemscope itemprop="url" itemtype="https://schema.org/URL" class="u-url"'
        }
        $Options = [Text.RegularExpressions.RegexOptions]'IgnoreCase, CultureInvariant'
    }
    Process {
        If ($Keyword.Count -gt 0) {
            $Text = [Regex]::Replace(
                $Text,
                $Custom,
                {
                    Param($Match)
                    $Value = $Match.Value
                    $ClassName = $Value -replace '\s', '-'
                    if ($Microformat) {
                        "<span $($Attributes.Custom) class=`"keyword p-item`" data-keyword=`"${ClassName}`">${Value}</span>"
                    } else {
                        "<span class=`"keyword`" data-keyword=`"${ClassName}`">${Value}</span>"
                    }
                }
            )
        }
        if ($Abbreviations.Count -gt 0) {
            $Items = $Abbreviations.GetEnumerator()
            foreach ($Item in $Items) {
                $Name = $Item.Name
                $Value = $Item.Value
                $Text = [Regex]::Replace(
                    $Text,
                    "\b${Value}\b",
                    {
                        Param($Match)
                        $Value = $Match.Value
                        "<abbr title=`"${Name}`">${Value}</abbr>"
                    }
                )
            }
        }
        if ('all' -notin $Disable) {
            switch ($True) {
                { 'url' -notin $Disable } {
                    $Text = [Regex]::Replace(
                        $Text,
                        $Url,
                        {
                            Param($Match)
                            $Value = $Match.Groups[1].Value
                            if ($Microformat) {
                                "<a $($Attributes.Url) href=`"${Value}`">${Value}</a>"
                            } else {
                                "<a href=`"${Value}`">${Value}</a>"
                            }
                        },
                        $Options
                    )
                }
                { 'date' -notin $Disable } {
                    $Text = [Regex]::Replace(
                        $Text,
                        $Date,
                        {
                            Param($Match)
                            $Value = $Match.Groups[1].value
                            $Data = $Value | Test-Match -Date
                            $IsoValue = [DateTime]"$($Data.Month)/$($Data.Day)/$($Data.Year)" | ConvertTo-Iso8601
                            if ($Microformat) {
                                "<time $($Attributes.Date) datetime=`"${IsoValue}`">${Value}</time>"
                            } else {
                                "<time datetime=`"${IsoValue}`">${Value}</time>"
                            }
                        },
                        $Options
                    )
                }
                { 'duration' -notin $Disable } {
                    $Text = [Regex]::Replace(
                        $Text,
                        $Duration,
                        {
                            Param($Match)
                            $Value = $Match.Groups[1].value
                            $Data = $Value | Test-Match -Duration
                            $Start = $Data.Start
                            $End = $Data.End
                            $Timezone = if ($Data.IsZulu) { ' data-timezone="Zulu"' } else { '' }
                            if ($Microformat) {
                                "<span $($Attributes.Duration)${Timezone}><time $($Attributes.Start) datetime=`"${Start}`">${Start}</time> - <time $($Attributes.End) datetime=`"${End}`">${End}</time></span>"
                            } else {
                                "<span class=`"duration`"${Timezone}><time datetime=`"${Start}`">${Start}</time> - <time datetime=`"${End}`">${End}</time></span>"
                            }
                        },
                        $Options
                    )
                }
                { 'email' -notin $Disable } {
                    $Text = [Regex]::Replace(
                        $Text,
                        $Email,
                        {
                            Param($Match)
                            $Value = $Match.Groups[1].Value
                            if ($Microformat) {
                                "<a $($Attributes.Email) href=`"mailto:${Value}`">${Value}</a>"
                            } else {
                                "<a href=`"mailto:${Value}`">${Value}</a>"
                            }
                        },
                        $Options
                    )
                }
                { 'ip' -notin $Disable } {
                    $Text = [Regex]::Replace(
                        $Text,
                        $IpAdress,
                        {
                            Param($Match)
                            $Value = $Match.Groups[1].Value
                            "<a class=`"ip`" href=`"${Value}`">${Value}</a>"
                        },
                        $Options
                    )
                }
            }
        }
        $Text
    }
}
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-EpochDate () {
    <#
    .SYNOPSIS
    Converts epoch dates into datetime values
    .PARAMETER Epoch
    The epoch to use in conversion
    (Default value is '01.01.1970')
    .EXAMPLE
    '1577836800' | ConvertFrom-EpochDate -AsString
    # '1/1/20'
    .EXAMPLE
    '1577836800000000' | ConvertFrom-EpochDate -Microseconds -AsString
    # '1/1/20'
    #>

    [CmdletBinding()]
    [OutputType([System.String])]
    [OutputType([DateTime])]
    Param(
        [Parameter(Position = 0, ValueFromPipeline = $True)]
        [BigInt] $Value,
        [Switch] $Milliseconds,
        [Switch] $Microseconds,
        [Switch] $AsString,
        [String] $Epoch = '01.01.1970',
        [String] $Format = 'M/d/y'
    )
    $Units = if ($Milliseconds) {
        1000
    } elseif ($Microseconds) {
        1000000
    } else {
        1
    }
    $Result = (Get-Date $Epoch) + ([System.TimeSpan]::fromseconds($Value / $Units))
    if ($AsString) {
        $Result.ToString($Format)
    } else {
        $Result
    }
}
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
    # {"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-HostsContent {
    <#
    .SYNOPSIS
    Get and parse contents of hosts file
    .PARAMETER Path
    Specifies an alternate hosts path. Defaults to %SystemRoot%\System32\drivers\etc\hosts.
    .EXAMPLE
    Get-HostsContent
    .EXAMPLE
    Get-HostsContent '.\hosts'
    #>

    [CmdletBinding()]
    [OutputType([String])]
    Param(
        [Parameter(Position = 0, ValueFromPipeline = $True)]
        [ValidateScript( { Test-Path $_ })]
        [String] $Path
    )
    if (-not $Path) {
        $Path = if (-not $IsLinux) {
            Join-Path $Env:SystemRoot 'System32\drivers\etc\hosts'
        } else {
            '/etc/hosts'
        }
    }
    $CommentLine = '^\s*#'
    $HostLine = '^\s*(?<IPAddress>[01-9\.\:]+)\s+(?<Hostname>[^#]+)(\s*|\s+#(?<Comment>.*))$'
    $HomeAddress = [Net.IPAddress]'127.0.0.1'
    $LineNumber = 0
    (Get-Content $Path -ErrorAction Stop) | ForEach-Object {
        if (($_ -match $HostLine) -and ($_ -notmatch $CommentLine)) {
            $IpAddress = $Matches['IPAddress']
            $Comment = if ($Matches['Comment']) { $Matches['Comment'] } else { '' }
            $Result = [PSCustomObject]@{
                LineNumber = $LineNumber
                IPAddress = $IpAddress
                IsValidIP = [Net.IPAddress]::TryParse($IPAddress, [Ref] $HomeAddress)
                Hostname = $Matches['Hostname'].Trim()
                Comment = $Comment.Trim()
            }
            $Result.PSObject.TypeNames.Insert(0, 'Hosts.Entry')
            $Result
        }
        $LineNumber++
    }
}
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 -Verbose:$False
        } else {
            $InputObject
        }
        $Elements = @()
        switch -Regex ($Selector) {
            '^[.].*' {
                $ClassName = $_ | Remove-Character -At 0
                foreach ($Element in $Html.all) {
                    if ($Element.className -eq $ClassName) {
                        $Elements += $Element
                    }
                }
                "==> [INFO] Found $($Elements.Count) element(s) with ${ClassName} class name" | Write-Verbose
            }
            '^#.*' {
                $Id = $_ | Remove-Character -At 0
                foreach ($Element in $Html.getElementById($Id)) {
                    $Elements += $Element
                }
                "==> [INFO] Found $($Elements.Count) element(s) with ${Id} ID" | Write-Verbose
            }
            Default {
                foreach ($Element in $Html.all.tags($Selector)) {
                    $Elements += $Element
                }
                "==> [INFO] Found $($Elements.Count) element(s) that match `"${_}`"" | Write-Verbose
            }
        }
        $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 Session
    Name for custom web session variable.
    This cmdlet will try to remember the session variable, by default. Use -DisableSession to disable this behavior.
    See https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/invoke-webrequest?view=powershell-7.2#example-2-use-a-stateful-web-service
    .PARAMETER Download
    Download content at URI.
    .PARAMETER WebRequestParameters
    Object for passing parameters to underlying invocation of Invoke-WebRequest
    Note: "Custom" is an alias for this parameter
    .EXAMPLE
    $Uri = 'https://api.github.com/notifications'
    $Query = @{ per_page = 100; page = 1 }
    $Response = Invoke-WebRequestBasicAuth $Uri -Token $Token -Query $Query -ParseContent
    $Response | Format-Table -AutoSize
 
    # Authenticate a GET request with a token
    .EXAMPLE
    $Uri = 'https://api.github.com/notifications'
    $Query = @{ per_page = 100; page = 1 }
    $Response = Invoke-WebRequestBasicAuth $Uri -Username $Username -Password $Token -Query $Query -ParseContent
    $Response | Format-Table -AutoSize
 
    # Use basic authentication with a username and password
    .EXAMPLE
    $Uri = 'https://api.github.com/notifications'
    @{ last_read_at = '' } | BasicAuth $Uri -Put -Token $Token
 
    # Execute a PUT request with a data payload
    .EXAMPLE
    $Uri = 'https://api.github.com/notifications'
    $Parameters = @{ SkipCertificateChecks = $True }
    @{ last_read_at = '' } | basicauth $Uri -Put -Token $Token -Custom $Parameters
    .EXAMPLE
    $Uri = 'https://db.ygoprodeck.com/api/v7/cardinfo.php
    basicauth $Uri -Query @{ name = 'Galaxy-Eyes Photon Dragon' } -ParseContent
 
    # Download and parse an API JSON response (can also parse HTML and CSV content)
    #>

    [CmdletBinding(DefaultParameterSetName = 'none', SupportsShouldProcess = $True)]
    [Alias('basicauth')]
    Param(
        [Parameter(ParameterSetName = 'basic')]
        [String] $Username,
        [Parameter(ParameterSetName = 'basic')]
        [String] $Password,
        [Parameter(ParameterSetName = 'token')]
        [String] $Token,
        [PSObject] $Headers = @{},
        [String] $Session = 'PreludeBasicAuthSession',
        [Switch] $DisableSession,
        [Parameter(Mandatory = $True, Position = 0)]
        [UriBuilder] $Uri,
        [PSObject] $Query = @{},
        [Switch] $UrlEncode,
        [Switch] $Download,
        [ValidateScript( { Test-Path $_ })]
        [String] $Folder = (Get-Location).Path,
        [Switch] $ParseContent,
        [Alias('OTP', '2FA')]
        [Switch] $TwoFactorAuthentication,
        [Switch] $Get,
        [Switch] $Post,
        [Switch] $Put,
        [Switch] $Delete,
        [Switch] $Github,
        [Switch] $Gitlab,
        [Parameter(ValueFromPipeline = $True)]
        [PSObject] $Data = @{},
        [Alias('Custom')]
        [PSObject] $WebRequestParameters = @{}
    )
    Begin {
        function Get-ParsedContent {
            Param(
                [Parameter(Mandatory = $True, Position = 0)]
                $Request
            )
            $Content = $Request.Content
            $Type = $Request.Headers.'Content-Type'
            switch -Regex ($Type) {
                '^text\/csv' {
                    $Content | ConvertFrom-Csv
                }
                '^text\/html' {
                    $Content | ConvertFrom-Html
                }
                '^application\/xhtml[+]xml' {
                    $Content | ConvertFrom-Html
                }
                '^application\/json' {
                    $Content | ConvertFrom-Json
                }
                '^text\/(plain|css|javascript)' {
                    "==> [INFO] Cannot parse content of type, ${Type}" | Write-Verbose
                    $Content
                }
                '^(image|video|font|audio|model)\/' {
                    "==> [INFO] Cannot parse content of type, ${Type}" | Write-Verbose
                    $Content
                }
                Default {
                    "==> [WARN] Unable to resolve type, ${Type}" | Write-Warning
                    $Content
                }
            }
        }
    }
    Process {
        if ($PSBoundParameters.ContainsKey('Password') -or $PSBoundParameters.ContainsKey('Token')) {
            $HeaderData = if ($Gitlab) {
                @{ 'PRIVATE-TOKEN' = $Token }
            } else {
                $Authorization = if ($Token.Length -gt 0) {
                    "Bearer $Token"
                } else {
                    $Credential = [Convert]::ToBase64String([System.Text.Encoding]::Ascii.GetBytes("${Username}:${Password}"))
                    "Basic $Credential"
                }
                @{ 'Authorization' = $Authorization }
            }
            $Headers = $Headers, $HeaderData | Invoke-ObjectMerge
        }
        if ($TwoFactorAuthentication) {
            if ($Github) {
                if ($PSCmdlet.ShouldProcess('GitHub 2FA')) {
                    'GitHub 2FA' | Write-Title -Green
                    $Code = 'Code:' | Invoke-Input -Number -Indent 4
                    $Headers.Accept = 'application/vnd.github.v3+json'
                    $Headers['x-github-otp'] = $Code
                } else {
                    '==> [DRYRUN] Would have set Accept and x-github-otp headers' | Write-Color -DarkGray
                }
            }
        }
        $Method = Find-FirstTrueVariable 'Get', 'Post', 'Put', 'Delete'
        $QueryString = $Query | ConvertTo-QueryString -UrlEncode:$UrlEncode
        $Parameters = @{
            Headers = $Headers
            Method = $Method
            Uri = "$($Uri.Uri)$(if ($QueryString.Length -gt 0) { '?' })${QueryString}"
        }
        if ($Method -in 'Post', 'Put') {
            $Parameters.Body = $Data | ConvertTo-Json
        }
        if (-not $DisableSession) {
            $WebSession = Get-Variable -Name $Session -ValueOnly -ErrorAction Ignore
            if ($WebSession -is [Microsoft.PowerShell.Commands.WebRequestSession]) {
                $Parameters.WebSession = $WebSession
            } else {
                $Parameters.SessionVariable = $Session
            }
        }
        $OutFile = Join-Path $Folder (Split-Path $Uri.Path -Leaf)
        if ($Download) {
            $Parameters.OutFile = $OutFile
        }
        if ($PSCmdlet.ShouldProcess('Invoke-WebRequest')) {
            $Parameters, $WebRequestParameters | Invoke-ObjectMerge | ConvertTo-Json | Write-Verbose
            $Request = Invoke-WebRequest @Parameters @WebRequestParameters -UseBasicParsing
        } else {
            '==> [DRYRUN] Would have called Invoke-WebRequest with the parameters:' | Write-Color -DarkGray
            $Parameters, $WebRequestParameters | Invoke-ObjectMerge | ConvertTo-Json | Write-Color -DarkGray
        }
        if ($ParseContent) {
            if ($PSCmdlet.ShouldProcess('Parse content')) {
                $Content = Get-ParsedContent $Request
                if ($Download) {
                    $Parameters = @{
                        Encoding = 'default'
                        FilePath = $OutFile
                    }
                    $Content | Out-File @Parameters | Out-Null
                } else {
                    $Content
                }
            } else {
                '==> [DRYRUN] Would have returned parsed response content' | Write-Color -DarkGray
            }
        } else {
            if ($PSCmdlet.ShouldProcess('Return request response')) {
                $Request
            } else {
                '==> [DRYRUN] Would have returned response' | Write-Color -DarkGray
            }
        }
    }
}
function New-GitlabRunner {
    <#
    .SYNOPSIS
    Create a new GitLab runner using the GitLab API (as opposed to using the GitLab GUI).
    .EXAMPLE
    [PSCustomObject]@{
        'Token' = $Token
        'Endpoint' = 'https://gitlab.com'
        'RunnerType' = 'Group'
        'Identifier' = $GroupId
        'Description' = 'My first runner'
    } | New-GitlabRunner
 
    .EXAMPLE
    Get-Content '.\runners.json' |
        ConvertFrom-Json |
        Get-Property 'items' |
        New-GitlabRunner -Token $PERSONAL_ACCESS_TOKEN
    #>

    [CmdletBinding(SupportsShouldProcess = $True)]
    [OutputType([String])]
    Param(
        [ValidateSet('https://gitlab.com', 'https://code.ornl.gov')]
        [Parameter(Mandatory = $True, ValueFromPipelineByPropertyName = $True)]
        [Alias('url')]
        [String] $Endpoint,
        [ValidateLength(20, 20)]
        [Parameter(Mandatory = $True, ValueFromPipelineByPropertyName = $True)]
        [String] $Token,
        [ValidateSet('Group', 'Project')]
        [Parameter(Mandatory = $False)]
        [Alias('type')]
        [String] $RunnerType = 'Group',
        [ValidateLength(5, 5)]
        [Parameter(Mandatory = $True, ValueFromPipelineByPropertyName = $True)]
        [Alias('id')]
        [String] $Identifier,
        [Parameter(Mandatory = $False, ValueFromPipelineByPropertyName = $True)]
        [String] $Description = 'Runner created with PowerShell',
        [Parameter(Mandatory = $False, ValueFromPipelineByPropertyName = $True)]
        [Switch] $Gpu = $False
    )
    Process {
        $Uri = "${Endpoint}/api/v4/user/runners"
        $RunnerData = switch ($RunnerType) {
            'Group' {
                @{
                    'group_id' = $Identifier
                    'runner_type' = 'group_type'
                }
            }
            'Project' {
                @{
                    'project_id' = $Identifier
                    'runner_type' = 'project_type'
                }
            }
        }
        $TagData = if ($Gpu.IsPresent) {
            @{ 'tag_list' = @('gpu') }
        } else {
            @{ 'tag_list' = @('cpu') }
        }
        $TagData | ConvertTo-Json | Write-Verbose
        $Query = $RunnerData, $TagData, @{
            'run_untagged' = $True
            'description' = $Description
        } | Invoke-ObjectMerge
        $Parameters = @{
            'Uri' = $Uri
            'Query' = $Query
            'Token' = $Token
            'Post' = $True
            'Gitlab' = $True
            'ParseContent' = $True
        }
        '==> GitLab API request parameters:' | Write-Verbose
        $Parameters | ConvertTo-Json | Write-Verbose
        if ($PSCmdlet.ShouldProcess('Make request to GitLab API to create a runner')) {
            $Response = Invoke-WebRequestBasicAuth @Parameters
            '==> GitLab API response:' | Write-Verbose
            $Response | ConvertTo-Json | Write-Verbose
            $Output = $Response, @{ 'gpu' = $Gpu.IsPresent } | Invoke-ObjectMerge
            $Output | ConvertTo-Json | Write-Verbose
            $Output
        }
    }
}
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
    #>

    [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 | Get-StringPath)")
            } 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 Register-GitlabRunner {
    <#
    .SYNOPSIS
    Register new GitLab docker executor runner within a gitlab/gitlab-runner Docker container.
    .DESCRIPTION
    - Docker must be installed and running
    - If gum is not installed, Force will effectively be treated as True
    .PARAMETER Token
    GitLab runner registration token
    https://docs.gitlab.com/runner/register/index.html
    .EXAMPLE
    Register-GitlabRunner -Token $Token -Identifier $RunnerId
    .EXAMPLE
    [PSCustomObject]@{
        'Token' = $Token
        'RunnerType' = 'Group'
        'Identifier' = $GroupId
        'Endpoint' = $Endpoint
        'Description' = $Description
    } | New-GitlabRunner | Register-GitlabRunner
    #>

    [CmdletBinding()]
    Param(
        [Parameter(Mandatory = $True, ValueFromPipelineByPropertyName = $True)]
        [String] $Token,
        [Parameter(Mandatory = $True, ValueFromPipelineByPropertyName = $True)]
        [Alias('id')]
        [String] $Identifier,
        [Parameter(Mandatory = $False, ValueFromPipelineByPropertyName = $True)]
        [Switch] $Gpu = $False,
        [Switch] $Force
    )
    Begin {
        $Tasks = @('Create', 'Register')
        $Image = 'gitlab/gitlab-runner:latest'
        $Passthru = '/var/run/docker.sock:/var/run/docker.sock'
        $LogLevel = if ($PSCmdlet.MyInvocation.BoundParameters.Verbose) { 'debug' } else { 'panic' }
    }
    Process {
        $Url = 'https://code.ornl.gov'
        $Name = "runner_${Identifier}"
        $GpuOptions = if ($Gpu.IsPresent) { @{ 'gpus' = 'all' } } else { @{} }
        $Parameters = @{
            'Create' = $GpuOptions, @{
                'detach' = $True
                'name' = $Name
                'restart' = 'always'
                'volume' = '/srv/gitlab-runner/config:/etc/gitlab-runner'
            } | Invoke-ObjectMerge | ConvertTo-ParameterString
            'Register' = @{
                'non-interactive' = $True
                'url' = $Url
                'token' = $Token
                'executor' = 'docker'
                'docker-image' = 'docker'
                'description' = "'GitLab Runner [${Identifier}]'"
                'docker-volumes' = $Passthru
            } | ConvertTo-ParameterString
        }
        $Command = @{
            'Create' = "docker run $($Parameters.Create) --volume ${Passthru} ${Image}"
            'Register' = "docker exec runner_${Identifier} gitlab-runner --log-level ${LogLevel} register $($Parameters.Register)"
        }
        $Execute = if (-not $Force -and (Test-Command 'gum')) {
            gum confirm "Create and register ${Name} container?"; if ($?) { $True } else { $False }
        } else {
            $True
        }
        if ($Execute) {
            $Tasks | ForEach-Object {
                '==> [INFO] Executing Docker commands' | Write-Verbose
                $Command[$_] | Write-Verbose
                Invoke-Expression $Command[$_] | Out-Null
            }
        } else {
            '==>[ERROR] User cancelled operation!' | Write-Color -Red
            '==> [INFO] You can remove the runner from your Gitlab instance with:' | Write-Color -Gray
            "==> basicauth -Delete -GitLab -Token `$Token -Uri ${Url}/api/v4/runners/${Identifier}" | Write-Color -Gray
        }
    }
}
function Save-File {
    <#
    .SYNOPSIS
    Download and save a file from a local or remote location.
    .PARAMETER SleepInterval
    Initial number of seconds to wait before checking if BitsTransfer job is complete.
    .PARAMETER WebClient
    Use .NET Web Client class instead of BitsTransfer.
    See https://docs.microsoft.com/en-us/dotnet/api/system.net.webclient
    .PARAMETER CustomParameters
    Parameters to pass to Start-BitsTransfer.
    .EXAMPLE
    'https://storage.googleapis.com/ygoprodeck.com/pics/93717133.jpg' | Save-File 'dragon.jpg'
    .EXAMPLE
    'https://storage.googleapis.com/ygoprodeck.com/pics/93717133.jpg' | Save-File 'dragon.jpg' -Asynchronous -Priority 'High'
    #>

    [CmdletBinding(DefaultParameterSetName = 'normal', SupportsShouldProcess = $True)]
    Param(
        [Parameter(Mandatory = $True, ValueFromPipeline = $True)]
        [UriBuilder] $Uri,
        [ValidateScript( { Test-Path $_ })]
        [String] $Destination = (Get-Location).Path,
        [Parameter(Position = 0)]
        [String[]] $Filename,
        [Parameter(ParameterSetName = 'asynchronous')]
        [Switch] $Asynchronous,
        [Parameter(ParameterSetName = 'asynchronous')]
        [Switch] $PassThru,
        [Parameter(ParameterSetName = 'asynchronous')]
        [ValidateSet('Foreground', 'High', 'Normal', 'Low')]
        [String] $Priority = 'Foreground',
        [Parameter(ParameterSetName = 'asynchronous')]
        [Int] $SleepInterval = 1,
        [Switch] $WebClient,
        [PSObject] $CustomParameters = @{}
    )
    Begin {
        function Format-FileVersion {
            Param(
                [Parameter(Mandatory = $True, Position = 0, ValueFromPipeline = $True)]
                [String] $Name
            )
            $Elapsed = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
            $Extension = [System.IO.Path]::GetExtension($Name)
            $Filename = $Name.Substring(0, $Name.Length - $Extension.Length)
            "${Filename}-${Elapsed}${Extension}"
        }
        function Test-JobComplete {
            Param(
                [Parameter(Mandatory = $True, Position = 0, ValueFromPipeline = $True)]
                $BitsJob
            )
            $State = $BitsJob.JobState
            ($State -ne 'Transferring') -and ($State -ne 'Connecting')
        }
        $Count = 0
        $CanUseBitsTransfer = Test-Command 'Start-BitsTransfer' -Silent
        $Client = if ($CanUseBitsTransfer -and (-not $WebClient)) {
            '==> [INFO] Using Start-BitsTransfer' | Write-Verbose
            $Null
        } else {
            '==> [INFO] Using .NET Web Client class' | Write-Verbose
            New-Object 'System.Net.WebClient'
        }
    }
    Process {
        $Name = if ($Filename.Count -gt 0) { $Filename[$Count] } else { Split-Path $Uri.Path -Leaf }
        $Path = Join-Path $Destination $Name
        if (Test-Path -Path $Path) {
            $Name = $Name | Format-FileVersion
            $Path = Join-Path $Destination $Name
        }
        if ($Client) {
            if ($PSCmdlet.ShouldProcess($Path)) {
                if ($Asynchronous) {
                    $Client.DownloadFileAsync($Uri.Uri, $Path)
                } else {
                    $Client.DownloadFile($Uri.Uri, $Path)
                }
                "==> [INFO] Saved file to ${Path} using WebClient." | Write-Verbose
            }
        } elseif ($CanUseBitsTransfer) {
            $Parameters = @{
                Asynchronous = $Asynchronous
                Destination = $Path
                DisplayName = 'PreludeBitsJob'
                Priority = $Priority
                Source = $Uri.Uri
                TransferType = 'Download'
            }
            $Job = Start-BitsTransfer @Parameters @CustomParameters
            if ($Asynchronous) {
                $Id = $Job.JobId
                "==> [INFO] Finishing BitsTransfer job [${Id}]..." | Write-Verbose
                if ($PassThru) {
                    return $Job
                } else {
                    $Seconds = $SleepInterval
                    while (-not (Test-JobComplete $Job)) {
                        "==> [INFO] BitsTransfer status [${Id}]: $($Job.JobState)" | Write-Verbose
                        Start-Sleep -Seconds $Seconds
                        $Seconds += 1
                    }
                    switch ($Job.JobState) {
                        'Transferred' {
                            Complete-BitsTransfer -BitsJob $Job
                            "==> [INFO] BitsTransfer Job [${Id}], complete." | Write-Verbose
                        }
                        'Error' {
                            "==> [ERROR] BitsTransfer Job [${Id}], failed." | Write-Error
                            $Job | Format-List
                        }
                        Default {
                            # Do nothing
                        }
                    }
                }
            }
            "==> [INFO] Saved file to ${Path} using BitsTransfer." | Write-Verbose
        }
        $Count += 1
    }
}
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 Update-HostsFile {
    <#
    .SYNOPSIS
    Update and/or add entries of a hosts file.
    .PARAMETER Path
    Specifies an alternate hosts path. Defaults to %SystemRoot%\System32\drivers\etc\hosts.
    .PARAMETER PassThru
    Outputs parsed HOSTS file upon completion.
    .EXAMPLE
    Update-HostsFile -IPAddress '127.0.0.1' -Hostname 'c2.evil.com'
    .EXAMPLE
    Update-HostsFile -IPAddress '127.0.0.1' -Hostname 'c2.evil.com' -Comment 'Malware C2'
    #>

    [CmdletBinding(SupportsShouldProcess = $True)]
    Param(
        [Parameter(Mandatory = $True, Position = 0)]
        [Alias('IP')]
        [Net.IpAddress] $IPAddress,
        [Parameter(Mandatory = $True, Position = 1)]
        [ValidateNotNullOrEmpty()]
        [Alias('Name')]
        [String] $Hostname,
        [Parameter(Position = 2)]
        [String] $Comment,
        [ValidateScript( { Test-Path $_ })]
        [String] $Path = (Join-Path $Env:SystemRoot 'System32\drivers\etc\hosts'),
        [Switch] $PassThru
    )
    $Raw = Get-Content $Path
    $Hosts = Get-HostsContent $Path
    $Comment = if ($Comment) { "# $Comment" } else { '' }
    $Entry = "$IpAddress $Hostname $Comment"
    $HostExists = $Hostname -in $Hosts.Hostname
    $Hosts | Where-Object { $_.Hostname -eq $Hostname } | ForEach-Object {
        if ($_.IpAddress -eq $IPAddress) {
            "The hostname, '$Hostname', and IP address, '$IPAddress', already exist in $Path." | Write-Verbose
        } else {
            if ($PSCmdlet.ShouldProcess($Path)) {
                "Replacing hostname, '$Hostname', in $Path." | Write-Verbose
                $Raw[$_.LineNumber] = $Entry
            } else {
                "==> Would be replacing hostname, '$Hostname', in $Path." | Write-Color -DarkGray
            }
        }
    }
    if (-not $HostExists) {
        if ($PSCmdlet.ShouldProcess($Path)) {
            "Appending '$Hostname' at '$IPAddress' to $Path." | Write-Verbose
            $Raw += "`n$Entry"
        } else {
            "==> Would be appending '$Hostname' at '$IPAddress' to $Path." | Write-Color -DarkGray
        }
    }
    $Raw | Out-File -Encoding ascii -FilePath $Path -ErrorAction Stop
    if ($PassThru) {
        Get-HostsContent $Path
    }
}
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()]
    [OutputType([Bool])]
    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
    }
}