Bempus-WebServer.psm1
|
$modulePaths = $env:PSModulePath -split ';' $webModulePath = "$PSScriptRoot/Modules" if ($modulePaths -notcontains $webModulePath) { $env:PSModulePath = ($modulePaths + $webModulePath) -join ';' } Class Request { [System.Net.HttpStatusCode]$statusCode = 200 Request() {} Request([hashtable]$request) { $this.statusCode = $request.statusCode } } Class ContentType { hidden [string]$value static [string]$TextPlain = "text/plain" static [string]$TextHTML = "text/html" static [string]$TextJavaScript = "text/javascript" static [string]$TextCSS = "text/css" static [string]$TextCSV = "text/csv" static [string]$ApplicationJSON = "application/json" static [string]$ApplicationGZIP = "application/gzip" static [string]$ApplicationZIP = "application/zip" static [string]$AudioMidi = "audio/midi" static [string]$AudioMP3 = "audio/mpeg" static [string]$VideoMP4 = "video/mp4" static [string]$videoWEBM = "video/webm" static [string]$ImagePng = "image/png" static [string]$ImageJpeg = "image/jpeg" static [string]$ImageGif = "image/gif" static [string]$ImageBmp = "image/bmp" static [string]$ImageIco = "image/vnd.microsoft.icon" static [string]$ImageSVG = "image/svg+xml" static [string]$FontWoff = "font/woff" static [string]$FontWoff2 = "font/woff2" ContentType([string]$value) { $this.Value = $value } static [ContentType] AsType([string] $value) { return $value } [string] ToString() { $allowedList = @( "text/plain", "text/html", "text/javascript", "text/css", "text/csv", "application/json", "application/gzip", "application/zip", "audio/mpeg" "video/webm", "video/mp4", "image/png", "image/jpeg", "image/gif", "image/bmp", "image/vnd.microsoft.icon", "image/svg+xml", "application/gzip", "font/woff", "font/woff2" ) if ($this.value -notin $allowedList) { throw """$($this.Value)"" is not a valid ContentType. Valid values:`n$($allowedList -join "`n" )" } return $allowedList | Where-Object { $_ -eq $this.value } } } Enum Method { GET POST } class WebServer { [System.Collections.ArrayList] hidden $endpoints = [System.Collections.ArrayList]::new() [System.Net.HttpListener] hidden $listener [string] hidden $titlePrefix = "" [char] hidden $titleDelimiter = $null [int16] hidden $port = 9001 [string] hidden $path = ((Get-Location).ProviderPath) hidden $body = @{} hidden $query = @{} hidden $store = @{} hidden $method = @{} hidden $request = [request]::new() [void] SetPath([string]$path) { $this.path = $path } [void] SetPort([int]$port) { $this.port = $port } [void] SetTitlePrefix([string]$titlePrefix, [char]$titleDelimiter = $null) { $this.titlePrefix = $titleprefix $this.titleDelimiter = $titleDelimiter } [void] hidden ResetRequest() { $this.method = [Method]::GET.ToString() $this.body = @{} $this.query = @{} $this.request = [Request]::new() } [void] hidden GetClosestPort([int16]$port) { if (Get-NetTCPConnection | Where-Object LocalPort -eq $port) { Write-Warning "Port $port is already in use, finding the next available" while (Get-NetTCPConnection | Where-Object LocalPort -eq $port) { $port++ } Write-Host "New port: $port" } $this.port = $port } [scriptblock] hidden ConvertToCallBack($string) { return [scriptblock]::Create("return [string]$string") } #---------------------------------------------------------------- # Endpoints (ADD) #---------------------------------------------------------------- #---------------------------------------------------------------- # Add Endpoint #---------------------------------------------------------------- [void]AddEndpoint([string]$name, [scriptblock]$callBack, [Method[]]$methods = "GET", [ContentType]$ContentType, [bool]$static = $false) { $name = $name -replace '^/?(.*)/?$', '$1' if ($this.endpoints | Where-Object name -eq $name) { Write-Error "An endpoint with name ""$name"" already exists" return } $null = $this.endpoints.Add(@{ name = $name methods = $methods ContentType = $contentType callback = $callback static = $static }) return } #---------------------------------------------------------------- # Add with Methods #---------------------------------------------------------------- [void] AddEndpoint([string]$name, [scriptblock]$callBack, [Method[]]$methods = "GET", [bool]$static = $false) { $this.AddEndpoint($name, $callBack, $methods, [ContentType]::textHTML, $static) } #---------------------------------------------------------------- # Add with ContentType #---------------------------------------------------------------- [void] AddEndpoint([string]$name, [scriptblock]$callBack, [string]$ContentType, [bool]$static = $false) { $this.AddEndpoint($name, $callBack, @('GET', 'POST'), $contentType, $static) } #---------------------------------------------------------------- # Add (Barebones) #---------------------------------------------------------------- [void] AddEndpoint([string]$name, [scriptblock]$callBack, [bool]$static = $false ) { $this.AddEndpoint($name, $callBack, @('GET', 'POST'), [ContentType]::textHTML, $static) } #---------------------------------------------------------------- # Add (Barebones, as string) #---------------------------------------------------------------- [void] AddEndpoint([string]$name, [string]$callBack, [bool]$static = $false ) { $this.AddEndpoint($name, $this.ConvertToCallBack($callBack), @('GET', 'POST'), [ContentType]::textHTML, $static) } #---------------------------------------------------------------- # Endpoints (ADD) END #---------------------------------------------------------------- #---------------------------------------------------------------- # Endpoints (UPDATE) #---------------------------------------------------------------- #---------------------------------------------------------------- # Update Endpoint (Full) #---------------------------------------------------------------- [void] UpdateEndpoint([string]$name, [scriptblock]$callback, [Method[]]$methods, [string]$contentType) { [hashtable]$endpoint = $this.endpoints | Where-Object Name -eq $name if (-not $endpoint) { Write-Warning "No endpoint whith the name $name exists" return } $endpoint.callback = $callback $endpoint.methods = $methods $endpoint.ContentType = $contentType } #---------------------------------------------------------------- # Update Callback #---------------------------------------------------------------- [void] UpdateEndpoint([string]$name, [scriptblock]$callback) { [hashtable]$endpoint = $this.endpoints | Where-Object Name -eq $name if (-not $endpoint) { Write-Warning "No endpoint whith the name $name exists" return } $this.UpdateEndpoint($name, $callback, $endpoint.methods, $endpoint.ContentType) } [void] UpdateEndpoint([string]$name, [string]$callback) { $this.UpdateEndpoint($name, $this.ConvertToCallBack($callBack)) } #---------------------------------------------------------------- # Update Methods #---------------------------------------------------------------- [void] UpdateEndpoint([string]$name, [Method[]]$methods) { [hashtable]$endpoint = $this.endpoints | Where-Object Name -eq $name if (-not $endpoint) { Write-Warning "No endpoint whith the name $name exists" return } $this.UpdateEndpoint($name, $endpoint.callback, $methods, $endpoint.ContentType) } #---------------------------------------------------------------- # Update Content Type #---------------------------------------------------------------- [void] UpdateEndpoint([string]$name, [ContentType]$contentType) { [hashtable]$endpoint = $this.endpoints | Where-Object Name -eq $name if (-not $endpoint) { Write-Warning "No endpoint whith the name $name exists" return } $this.UpdateEndpoint($name, $endpoint.callback, $endpoint.methods, $ContentType) } #---------------------------------------------------------------- # Update Callback and Method #---------------------------------------------------------------- [void] UpdateEndpoint([string]$name, [scriptblock]$callback, [Method[]]$methods) { [hashtable]$endpoint = $this.endpoints | Where-Object Name -eq $name if (-not $endpoint) { Write-Warning "No endpoint whith the name $name exists" return } $this.UpdateEndpoint($name, $callback, $methods, $endpoint.ContentType) } [void] UpdateEndpoint([string]$name, [string]$callback, [Method[]]$methods) { $this.UpdateEndpoint($name, $this.ConvertToCallBack($callBack), $methods) } #---------------------------------------------------------------- # Update Callback and ContentType #---------------------------------------------------------------- [void] UpdateEndpoint([string]$name, [scriptblock]$callback, [ContentType]$contentType) { [hashtable]$endpoint = $this.endpoints | Where-Object Name -eq $name if (-not $endpoint) { Write-Warning "No endpoint whith the name $name exists" return } $this.UpdateEndpoint($name, $callback, $endpoint.methods, $ContentType) } [void] UpdateEndpoint([string]$name, [string]$callback, [ContentType]$contentType) { $this.UpdateEndpoint($name, $this.ConvertToCallBack($callBack), $contentType) } #---------------------------------------------------------------- # Update Methods and Content Types #---------------------------------------------------------------- [void] UpdateEndpoint([string]$name, [Method[]]$methods, [ContentType]$contentType) { [hashtable]$endpoint = $this.endpoints | Where-Object Name -eq $name if (-not $endpoint) { Write-Warning "No endpoint whith the name $name exists" return } $this.UpdateEndpoint($name, $endpoint.callback, $methods, $ContentType) } #---------------------------------------------------------------- # Endpoints (UPDATE) END #---------------------------------------------------------------- #---------------------------------------------------------------- # Creates the listener if it does not exist #---------------------------------------------------------------- [void] hidden CreateListener() { if ($this.listener) { return } $this.listener = [System.Net.HttpListener]::new() $this.listener.Prefixes.Add("http://localhost:$($this.port)/") $this.listener.Start() } #---------------------------------------------------------------- #---------------------------------------------------------------- #---------------------------------------------------------------- # Adds Endpoints from the Pages Folder #---------------------------------------------------------------- [void] hidden AddPagesEndpoints($item) { Get-ChildItem $item.fullName | ForEach-Object { if ($_.Attributes -eq 'Directory') { return $this.AddPagesEndpoints($_) } <#if ($_.Extension -ne '.html') { return }#> $path = '/' + ($_.FullName -replace ("$(($this.Path -replace '.*::') -replace '\\', '\\')\\pages\\") -replace '(index|).html' -replace '\\', '/' -replace '/$') if (($path -replace '^/') -in $this.endpoints.Name) { return } $this.AddEndpoint($path, [scriptblock]::Create("Get-Content -Path '$($_.FullName)' -Raw -encoding UTF8"), $true ) $this.endpoints | Where-Object name -eq ($path -replace '^/') | ForEach-Object { $_.Base = $item.fullName -replace ($this.Path -replace '.*::' -replace '\\', '\\') -replace '\\', '/' $_.PageBase = $_.Base -replace '^/Pages' } } } #---------------------------------------------------------------- #---------------------------------------------------------------- #---------------------------------------------------------------- # Adds Endpoints from the API Folder (.ps1 files) #---------------------------------------------------------------- [void] hidden AddAPIEndpoints($item) { Get-ChildItem -Path $item.fullName | ForEach-Object { if ($_.Attributes -eq 'Directory') { return $this.AddAPIEndpoints($_) } if ($_.Extension -ne '.ps1') { return } $path = ($_.FullName -replace ($this.path -replace '\\', '\\') -replace '(index|).ps1' -replace '\\', '/' -replace '/$') if (($path -replace '^/') -in $this.endpoints.Name) { return } $content = (Get-Content $_.FullName) -join "`n" if ($content -match "^methods?:") { $Methods = ($content -replace 'methods?:(.*)$', '$1' -split ',') | ForEach-Object { [Method]($_.Trim()) } } else { $Methods = @([Method]::GET, [Method]::POST) } if ($content -match '^#html$') { $contentType = [ContentType]::textHTML } else { $contentType = [ContentType]::ApplicationJSON } $this.AddEndpoint($path, [scriptblock]::Create(". '$($_.FullName)' -body `$this.body -query `$this.query -method `$this.method -store `$this.store -request `$this.request" ), $Methods, $contentType, $true) } } #---------------------------------------------------------------- # Retrieves the Paths to Static Files # Expects a Pages folder for Static Endpoints # - Expects .html files, ignores others # Expects an API folder for Static APIs # - Expects .ps1 files, ignores others #---------------------------------------------------------------- [void] LoadStaticEndpoints() { $_endpoints = [System.Collections.ArrayList]::new() $this.endpoints | Where-Object { -not $_.static } | ForEach-Object { $_endpoints.Add($_) } $this.endpoints = $_endpoints $pagesPath = (Join-Path -path $this.path -ChildPath 'pages') $apiPath = (Join-Path -path $this.path -ChildPath 'api') if (Test-Path $pagesPath) { $this.AddPagesEndpoints((Get-Item -Path $pagesPath)) } if (Test-Path $apiPath) { $this.AddAPIEndpoints((Get-Item -Path $apiPath)) } } #---------------------------------------------------------------- # Starts the server on specified port # (If port is in use, the next avaliable port will be used) #---------------------------------------------------------------- [void]Start([int16]$port) { $this.port = $port function Send-Response { param( [System.Net.HttpListenerResponse]$res, [string]$message, [string]$path, [ContentType]$contentType = [ContentType]::textHTML ) try { if ($path) { $path = $path -replace '.*::' $fstream = [System.IO.FileStream]::new($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read) [byte[]]$buffer = [byte[]]::new($fstream.Length) $fstream.Read($buffer, 0, $fstream.Length) $fstream.close() } else { [byte[]]$buffer = [System.Text.Encoding]::UTF8.GetBytes($message) } $res.ContentType = $contentType $res.contentLength64 = $buffer.Length $output = $res.OutputStream $output.write($buffer, 0, $buffer.Length) $output.close() } catch { Write-Host $path Write-Host $_ -ForegroundColor Red } } $this.CreateListener() $this.LoadStaticEndpoints() Write-Host "Server started on port $($this.port) (http://localhost:$($this.port))" -ForegroundColor Green Write-Host "Avaliable endpoints:" #---------------------------------------------------------------- # Writes all endpoints to console #---------------------------------------------------------------- foreach ($endpoint in $this.endpoints) { if (($endpoint.name -match '\.[a-zA-Z]{2,4}$') -and ($endpoint.name -notmatch '^/API/.*\.psm?1')) { continue } $color = if ($endpoint.name -like 'api/*') { [System.ConsoleColor]::Gray } else { [System.ConsoleColor]::Magenta } Write-Host "/$($endpoint.name)" -ForegroundColor $color } Write-Host "/`$end (Closes the server)" -ForegroundColor Yellow #---------------------------------------------------------------- # Reads the request and send response #---------------------------------------------------------------- Function Invoke-Request { param([System.Net.HttpListenerContext]$context) $req = $context.Request $res = $context.Response #Write-Host ($req | ConvertTo-Json -Depth 10) $this.ResetRequest() $this.LoadStaticEndpoints() if ( $req.url -match "$($this.port)/\`$end/?$") { Send-Response -req $req -res $res -message "Server Closed" $this.Stop() return 'Stop' } $urlPath = Join-Path -Path $this.path -ChildPath ($req.url -replace ".*localhost:$($this.port)") $ext = if (Test-Path -Path $urlPath) { (Get-Item -Path $urlPath).Extension -replace '\.' } if ($ext) { $item = Get-Item -Path $urlPath try { $content = (Get-Content $urlPath -errorAction Stop -Encoding UTF8) -join "`n" $contentType = switch ($ext) { 'js' { [ContentType]::textJavaScript } 'css' { [ContentType]::textCSS } 'html' { [ContentType]::textHTML } 'png' { [ContentType]::ImagePng } { $_ -match '^jpe?g$' } { [ContentType]::ImageJpeg } 'gif' { [ContentType]::ImageGif } 'mp3' { [ContentType]::AudioMP3 } 'mp4' { [ContentType]::VideoMP4 } 'ico' { [ContentType]::ImageIco } 'bmp' { [ContentType]::ImageBmp } 'svg' { [ContentType]::ImageSVG } Default { [ContentType]::textHTML } } } catch { $res.statusCode = 404 $content = "404 - Not Found" $contentType = [ContentType]::textHTML } if ($contentType -notlike 'text*') { Send-Response -res $res -path $urlPath -contentType $contentType } else { Send-Response -res $res -message $content -contentType $contentType } return } $url = ([string]$req.Url.AbsolutePath) -replace '^/?(.*)(/|)$', '$1' $endpoint = $this.endpoints | Where-Object Name -eq $url $this.Method = $req.HttpMethod if (-not $endpoint) { $res.statusCode = 404 Send-Response -res $res -message "Not Found" return } if (-not ($req.HttpMethod -in $endpoint.methods)) { $res.statusCode = 405 Send-Response -res $res -message "Method Not Allowed" return } $message = try { if ($req.HasEntityBody) { [System.IO.StreamReader]::new($req.InputStream).ReadLine() | ForEach-Object { try { $item = $_ | ConvertFrom-Json $item.PSObject.Properties | ForEach-Object { $this.body.($_.Name) = $_.Value } } catch {} } } $req.Url.Query -replace '^\?' -split '&' | ForEach-Object { $key, $value = $_ -split '=' $this.query.$key = [System.Net.WebUtility]::UrlDecode(($value -join '=')) } $ContentType = $endpoint.ContentType $result = Invoke-Command -ScriptBlock $endpoint.callback -ErrorAction Stop $baseUrl = $endpoint.PageBase if ($this.titlePrefix) { $result = $result -replace '<title>(.*)?</title>', "<title>$($this.titlePrefix) $($this.titleDelimiter) `$1</title>" } $result = $result -replace '(<(script|img|link|a) .*?(src|href) ?= ?")(?!http)(\./()|(\w))(.*?>)', "`$1$($baseUrl)/`$6`$7" if (-not $result) { $res.statusCode = 204 } if ($this.request.statusCode -is [System.Net.HttpStatusCode] -and $this.request.statusCode -ne [System.Net.HttpStatusCode]::OK) { $res.statusCode = $this.request.statusCode } Send-Response -res $res -message $result return } catch { $res.StatusCode = 400 Write-Host ($_.Exception.Message) -ForegroundColor Red $ContentType = [ContentType]::ApplicationJSON @{ status = 400 Message = "Bad Request" } | ConvertTo-Json -Compress } Send-Response -res $res -message $message -contentType $ContentType } #---------------------------------------------------------------- # Request handler #---------------------------------------------------------------- while ($true) { try { $context = $this.listener.GetContext() $status = Invoke-Request -context $context if ($status -eq 'Stop') { return } } catch [System.Management.Automation.MethodInvocationException] { throw $_ } catch { Write-host $_ -ForegroundColor red } } } #---------------------------------------------------------------- #---------------------------------------------------------------- #---------------------------------------------------------------- # Starts the WebServer on the Instance's default port #---------------------------------------------------------------- [void]Start() { $this.GetClosestPort($this.port) $this.Start($this.port) } #---------------------------------------------------------------- # Starts the WebServer on specified port and opens Edge in app-mode #---------------------------------------------------------------- [void]Start([int16]$port, [switch]$InBrowser) { $this.GetClosestPort($port) $this.CreateListener() $this.OpenInBrowser() $this.Start($this.port) } #---------------------------------------------------------------- # Starts the WebServer on the Instance's default port and opens Edge in app-mode #---------------------------------------------------------------- [void]Start([switch]$InBrowser) { $this.GetClosestPort($this.port) $this.CreateListener() $this.OpenInBrowser() $this.Start($this.port) } #---------------------------------------------------------------- # Stops the WebServer #---------------------------------------------------------------- [void]Stop() { if (-not $this.listener.IsListening) { Write-Host "Server is not running" -ForegroundColor Yellow return } $this.listener.Stop() $this.listener.Close() } #---------------------------------------------------------------- # Script for creating a new browser-window #---------------------------------------------------------------- [void] hidden OpenInBrowser() { try { Start-Job -ScriptBlock { param($port) Start-Process -FilePath (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\msedge.exe\')."(default)" -ArgumentList "--app=http://localhost:$($port)" } -ArgumentList $this.port } catch {} } } #---------------------------------------------------------------- # #---------------------------------------------------------------- <# .SYNOPSIS Exposed function to create and start a new WebServer .DESCRIPTION Exposed function to create and start a new WebServer, allowing setting port number, initial path, title prefix and delimiter (string, char), autostart with or without headless browser (Edge) .NOTES Works only on Windows for now .LINK https://github.com/bempus/PowerShell-WebServer .EXAMPLE New-WebServer Returns a new [WebServer] with port 3000 .Example New-WebServer -port 9001 Returns a new [WebServer] with port 9001 .Example New-WebServer -path 'C:\Temp\WebServer' Returns a new [WebServer] with port 3000 and Path 'C:\Temp\WebServer' #> function New-WebServer { param([int]$port = 3000, [string]$path, [string]$titlePrefix, [char]$titleDelimiter, [switch]$autoStart, [switch]$autoStartInBrowser) $ws = [WebServer]::new() $ws.SetPort($port) if ($path) { $ws.SetPath($path) } if ($titlePrefix -and $titleDelimiter) { $ws.SetTitlePrefix($titlePrefix, $titleDelimiter) } if ($autoStart) { $ws.Start() return } if ($autoStartInBrowser) { $ws.Start($true) return } return $ws } |