Public/Show-PSWebGUI.ps1
|
Function Show-PSWebGUI { [CmdletBinding()] param( [Parameter(Mandatory=$false,Position=0,ValueFromPipeline=$true)][Alias("Routes","Input")]$InputObject="", [Parameter(Mandatory=$false)][int]$Port=80, [Parameter(Mandatory=$false)][string]$Title="PoweShell Web GUI", [Parameter(Mandatory=$false)][string]$Icon, [Parameter(Mandatory=$false)][Alias("Root")][string]$DocumentRoot=$PWD.path, [Parameter(Mandatory=$false)][ValidateSet("NoGUI", "NoConsole", "Systray")][string]$Display, [Parameter(Mandatory=$false)][switch]$NoHeadTags, [Parameter(Mandatory=$false)][switch][Alias("Public")]$PublicServer, [Parameter(Mandatory=$false)][string]$Page404, [Parameter(Mandatory=$false)][switch]$AsJob ) # Start the server in a background job. Calling the function itself in background and break the execution in foreground if ($AsJob){ $parameters=$PSBoundParameters $parameters.AsJob=$false Start-Job -ScriptBlock { $parameters=$using:parameters Show-PSWebGUI @parameters } break # Avoid to continue normal execution in foreground } # Hide the PS console if parameter -Display "NoConsole" is set if ($Display -eq "NoConsole"){ Hide-PSConsole } # URL + PORT to use if ($PublicServer){ $url="http://+:$port/" }else{ $url="http://localhost:$port/" } # Create virtual drive in root directory $fileserver=New-PSDrive -Name FileServer -PSProvider FileSystem -Root $DocumentRoot # Scriptblock to execute when closing server $global:_CLOSESCRIPT={} # Global $_SERVER variables $global:_SERVER=@{ "PORT"=$port "Document_Root"=$DocumentRoot "PID"=$PID "URL"=$url } # Save PID and Port for this instance in a temp file $instance_properties=[PSCustomObject]@{ "PID"="$PID" "Port"="$port" "URL"="$url" "Start time"=Get-Date -Format "yyyy-MM-dd hh:mm:ss" } $instance_properties | ConvertTo-Json | Out-File -FilePath "$env:temp\pswebgui_$port.tmp" Write-Verbose "Instance properties saved in $env:temp\pswebgui_$port.tmp" #region Path cleaning <# =================================================================== INPUTOBJECT VALIDATION AND PATH CLEANING =================================================================== Validates $InputObject. Clean paths in $InputObject. Remove duplicated "/", dots and last "/" #> # If $InputObject is null, throw an error and stop execution if ($InputObject -eq $null){ Write-Error -Message "Input object is null" -Category InvalidArgument -CategoryTargetName "-InputObject" -CategoryTargetType "Null" -RecommendedAction "Do not set -InputObject if you don't want to pass any value" break } # If $InputObject is not string or hashtable, throw an error and stop execution if (!($InputObject -is [hashtable]) -and !($InputObject -is [string])){ Write-Error -Message "Object type not valid for InputObject. Only [String] or [hashtable] accepted" -Category InvalidType -CategoryTargetName "-InputObject" -CategoryTargetType "InvalidObjectType" break } # If $InputObject is a hashtable (not a string) if ($InputObject -is [hashtable]){ # If $InputObject does not contain index key "/", throw an error and stops execution If (!$InputObject.ContainsKey("/")){ Write-Error -Message "Index path ('/') not found in input object" -Category InvalidData -CategoryTargetName "'/'" -CategoryTargetType "Not found" break } # Get keys $keys=$($InputObject.Keys) # Foreach key $keys | foreach { $oldkey=$_ # If there are /exit() or /stop() urls, trow an error if (($_ -eq "/exit()") -or ($_ -eq "/stop()")){ Write-Error -Message "$_ url is reserved" -Category InvalidData -CategoryTargetName "$_" -CategoryTargetType "Omited" } # If key length > 1 (ignore root "/") if ($oldkey.length -gt 1){ # Remove last "/". This not generate error $oldkey=$oldkey -replace '\/+$','' # Remove dots at the end and betwen "/" and whitespaces $newkey=$oldkey -replace '\/*\.+\/*$|\.+(?=\/)|\s' # Replace many "/" with just one of them $newkey=$newkey -replace '\/{2,}','/' # If a modifictaion has made. (Removed last "/" doesnt count) if ($newkey -ne $oldkey){ # Send a warning Write-Warning -Message "URL is not well formed. URL: $oldkey -> $newkey" # Create clean key with old content (value) $InputObject[$newkey]=$InputObject[$oldkey] # Remove old key $InputObject.Remove($oldkey) } } } } #endregion #region Favicon <# =================================================================== FAVICON PROCESSING =================================================================== Vars: - $icon: string function parameter - $iconpath: Full absolute icon path used in WPF - $favicon: Relative icon path to $DocumentRoot, used in HTML (favicon) #> # First, test if icon path has been passed (as parameter) if ($icon -ne ""){ # If icon path exists as an absolute path (absolute path passed) if (Test-Path $icon){ # $iconpath is icon path itself $iconpath=$icon # $favicon is empty for now $favicon="" # If icon is inside $DocumentRoot, get relative path for favicon if ($icon.Contains($DocumentRoot)){ $favicon=$icon.Substring($DocumentRoot.Length,$icon.Length-$DocumentRoot.Length).Replace("\","/") } # If icon path exists as relative to root (relative path passed) }elseif (Test-Path "$DocumentRoot/$icon"){ # Get the absolute path for WPF $iconitem=get-item "$DocumentRoot/$icon" $iconpath=$iconitem.FullName # $favicon is icon relative path itself $favicon=$icon } } #endregion #region Page 404 processing <# =================================================================== PAGE 404 PROCESSING =================================================================== #> if ($page404){ if (Test-Path $page404 -PathType Leaf -Include "*.html","*.htm","*.txt","*.xhtml"){ $page404HTML=Get-Content $page404 } else{ Write-Error -Message "Page404 parameter must be a file with one of these extensions: html, hmt, xhtml, txt" -Category InvalidData -CategoryTargetName "$page404" -CategoryTargetType "Invalid file" } } #endregion #region Starting server <# =================================================================== STARTING SERVER =================================================================== #> # Create HttpListener Object $SimpleServer = New-Object Net.HttpListener # Tell the HttpListener what port to listen on $SimpleServer.Prefixes.Add($url) # Start up the server $SimpleServer.Start() # Load bootstrap $bootstrapContent=Get-Content "$PSScriptRoot\..\Assets\bootstrap.min.css" Write-Host "GUI started" -ForegroundColor Green #endregion #region Graphic interface <# =================================================================== GRAPHIC INTERFACE (GUI) =================================================================== #> # If -Display NoGUI, dont create an internal WebBrowser if ($Display -ne "NoGUI"){ # Create a scriptblock that waits for the server to launch and then opens a web browser control $UserWindow = { param ($port,$title,$iconpath,$display) # XAML [void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework') [xml]$XAML = @' <Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="PoweShell Web GUI" WindowStartupLocation="CenterScreen"> <Window.TaskbarItemInfo> <TaskbarItemInfo/> </Window.TaskbarItemInfo> <WebBrowser Name="WebBrowser"></WebBrowser> </Window> '@ #Read XAML $reader=(New-Object System.Xml.XmlNodeReader $xaml) $Form=[Windows.Markup.XamlReader]::Load( $reader ) # Set title and icon $Form.Title=$title $Form.Icon=$iconpath # Icon for window title bar $Form.TaskbarItemInfo.Overlay=$iconpath # Icon for taskbar # URL for GUI $guiURL="http://localhost:$port/" $exiturl=$guiURL+"exit()" # WebBrowser navigate to localhost $WebBrowser = $Form.FindName("WebBrowser") $WebBrowser.Navigate($guiURL) if ($Display -eq "Systray"){ Show-SystrayMenu } else{ # Show GUI $Form.ShowDialog() Start-Sleep -Seconds 1 # Once the end user closes out of the browser we send the exit url to tell the server to shut down. (New-Object System.Net.WebClient).DownloadString($exiturl); } } # Prepare the initial session state for runspace. Pass the Show-SystrayMenu function definition $ShowSystrayMenu_function_definition = Get-Content Function:\Show-SystrayMenu $SessionStateFunction = New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry -ArgumentList 'Show-SystrayMenu', $ShowSystrayMenu_function_definition $InitialSessionState= [InitialSessionState]::CreateDefault() $InitialSessionState.Commands.Add($SessionStateFunction) # Create runspace for GUI $RunspacePool = [RunspaceFactory]::CreateRunspacePool($InitialSessionState) $RunspacePool.ApartmentState = "STA" $RunspacePool.Open() $Jobs = @() # Create job and add to runspace $Job = [powershell]::Create().AddScript($UserWindow).AddArgument($port).AddArgument($title).AddArgument($iconpath).AddArgument($display)#.AddArgument($_) $Job.RunspacePool = $RunspacePool $Jobs += New-Object PSObject -Property @{ RunNum = $_ Pipe = $Job Result = $Job.BeginInvoke() } } #endregion #region Server requests <# =================================================================== SERVER REQUETS =================================================================== Vars: - $Context.Request: Contains details about the request - $Context.Response: Is basically a template of what can be sent back to the browser - $Context.User: Contains information about the user who sent the request. This is useful in situations where authentication is necessary #> while($SimpleServer.IsListening) { Write-Verbose "Listening for request" # Tell the server to wait for a request to come in on that port. $Context = $SimpleServer.GetContext() #Once a request has been captured the details of the request and the template for the response are created in our $context variable Write-Verbose "Context has been captured" # Sometimes the browser will request the favicon.ico which we don't care about. We just drop that request and go to the next one. if($Context.Request.Url.LocalPath -eq "/favicon.ico") { do { $Context.Response.Close() $Context = $SimpleServer.GetContext() }while ($Context.Request.Url.LocalPath -eq "/favicon.ico") } <# SERVER EXIT #> # Creating a friendly way to shutdown the server if($Context.Request.Url.LocalPath -eq "/stop()" -or $Context.Request.Url.LocalPath -eq "/exit()") { # Invoke scriptblock before stop server $_CLOSESCRIPT.Invoke() # Write instance properties file again, in case it was deleted $instance_properties | ConvertTo-Json | Out-File -FilePath "$env:temp\pswebgui_$port.tmp" Write-Verbose "Instance properties saved in $env:temp\pswebgui_$port.tmp" # Send a text to inform about the server stopped. Send different message dependig if -Display NoGUI was set if ($Display -ne "NoGUI"){ $result="<script>document.title='Server stopped. Bye!'</script>Server stopped. Please, close the GUI window. Bye!" } # -Display NoGUI set else{ $result="<script>document.title='Server stopped. Bye!'</script>Server stopped. Bye!" } $buffer = [System.Text.Encoding]::UTF8.GetBytes($result) $context.Response.ContentLength64 = $buffer.Length $context.Response.OutputStream.Write($buffer, 0, $buffer.Length) # Close response and stop the server $Context.Response.Close() $SimpleServer.Stop() Write-Verbose "Server stopped" # -Display NoGUI or Systray, dont close a non-existent Window if ($Display -ne "NoGUI" -and $Display -ne "Systray"){ $RunspacePool.Close() Write-Verbose "GUI closed" } # Remove properties file Remove-Item "$env:temp\pswebgui_$port.tmp" -Force break } #region Handly URLs <# =================================================================== HANDLY URLS =================================================================== #> #region Header tags # If -NoHeadTags is set, do not display html header tags If ($NoHeadTags -eq $false){ # Make html head template $htmlhead=(Get-Content -Path "$PSScriptRoot\..\Assets\htmlHeadTemplate.html" -Encoding UTF8).Replace("@@favicon",$favicon).Replace("@@style",$bootstrapContent).Replace("@@title",$title) # Style can't be applied with a <link href=''> tag because the browsers security restriction to access system local paths. For this reason, the style must be applied in # raw format right between a <style> tag. # Closing tags $htmlclosing="`n</body>`n</html>" } #endregion #region Method processing # POST processing if ($Context.Request.HasEntityBody){ $global:_SERVER["REQUEST_METHOD"]="POST" $request = $Context.Request $length = $request.contentlength64 $buffer = new-object "byte[]" $length [void]$request.inputstream.read($buffer, 0, $length) $body = [system.text.encoding]::ascii.getstring($buffer) # Split post data $global:_POST = @{} $body.split('&') | ForEach-Object { $part = $_.split('=') # POST variable name $post_name=$part[0] # Decode POST variable value $post_value=[System.Web.HttpUtility]::UrlDecode($part[1]) # If post variable name is already in $_POST collection, add new value to array if ($global:_POST.ContainsKey($post_name)){ [array]$global:_POST[$post_name]+=$post_value } else{ $global:_POST.add($post_name, $post_value) } } # GET processing }else{ $global:_SERVER["REQUEST_METHOD"]="GET" $global:_GET = [System.Web.HttpUtility]::ParseQueryString($Context.Request.Url.Query) } #endregion #region URL content processing # $localpath is the relative URL (/home, /user/support) $localpath=$Context.Request.Url.LocalPath $global:_SERVER["REQUEST_URI"]=$localpath # Remove last / in URL, if URL is */ if ($localpath.Length -gt 1){ $localpath=$localpath -replace '\/+$','' } # If $localpath is not a custom defined path in $InputObject, means it can be a filesystem path or a string if ($InputObject[$LocalPath] -eq $null){ # $localpath is a file if (Test-Path "FileServer:$localpath" -PathType Leaf){ $getContentParams=@{ Path="FileServer:$localpath" ReadCount=0 } # Compatibility with newest and older PS Version for Get-Content command if ($PSVersionTable.PSVersion.Major -gt 6){ $getContentParams.AsByteStream=$true } else{ $getContentParams.Encoding="Byte" } # Convert the file content to bytes from path $buffer = Get-Content @getContentParams # $InputObject is a string and $localpath is in / }elseif (($InputObject -is [string]) -and ($localpath -eq '/')){ Write-Verbose "A [string] object was returned." $result="$htmlhead $InputObject $htmlclosing" $buffer = [System.Text.Encoding]::UTF8.GetBytes($result) $context.Response.ContentLength64 = $buffer.Length } # $localpath is neither a file nor a defined route but is representing a path that its not found else{ if ($page404HTML){ $result=$page404HTML }else{ $result="<html>`n<head>`n<title>404 Not found</title>`n<body>`n<h1>404 Not found</h1>`n</body>`n</html>" } $Context.Response.StatusCode=404 $buffer = [System.Text.Encoding]::UTF8.GetBytes($result) $context.Response.ContentLength64 = $buffer.Length } # $localpath is defined in $InputObject, so is not a filesystem path }else{ # Get the content or script defined for this path $routecontent=$InputObject[$LocalPath] # Get the current title $originaltitle=$title # Execute the scriptblock $result="$htmlhead $(.$routecontent)" # Add closing html tags $result+="$htmlclosing" # Convert the result to bytes from UTF8 encoded text $buffer = [System.Text.Encoding]::UTF8.GetBytes($Result) # Let the browser know how many bytes we are going to be sending $context.Response.ContentLength64 = $buffer.Length } #endregion #endregion #region Send response and close Write-Verbose "Sending response of $Result" # Send the response back to the browser $context.Response.OutputStream.Write($buffer, 0, $buffer.Length) # Close the response to let the browser know we are done sending the response $Context.Response.Close() # Clear POST and GET variables before read another request Clear-Variable -Name "_POST","_GET" -Scope Global -ErrorAction SilentlyContinue $global:_SERVER.Remove("REQUEST_METHOD") Write-verbose $Context.Response #endregion } #endregion } |