Commands/Start-OpenPackage.ps1
|
function Start-OpenPackage { <# .SYNOPSIS Starts a OpenPackage Server .DESCRIPTION Starts a server, using one or more archive packages as the storage. .NOTES If a URI in the package is requested, that URI will be returned. If a path does not have an extension, it will search for an .index.html. If the file was not found, a 404 code will be returned. If the package contains a `/404.html`, the content in this file will be returned with the 404 If another method than GET or HEAD is used, a 405 code will be returned. If the package contains a `/405.html`, then content in this file will be returned with the 405. .LINK Get-OpenPackage #> [Alias('Start-OP','stOpenPackage')] [CmdletBinding(PositionalBinding=$false)] param( # The path to an Open Package file, or a glob that matches multiple Open Package files. [Parameter(ValueFromRemainingArguments)] [Alias('Arguments','Args','At','Url', 'AtUri', 'FilePath','Repository','Nuget')] [PSObject[]] $ArgumentList, # The root url. # By default, this will be automatically to a random local port. # If running elevated, can be any valid http listener prefix, including `http://*/` [string] $RootUrl = "http://127.0.0.1:$(Get-Random -Minimum 4200 -Maximum 42000)/", # The input object. This can be provided to avoid loading a file from disk. [Parameter(ValueFromPipeline)] [Alias('Package')] [PSObject[]] $InputObject, # The allowed http verbs. [string[]] $Allow = @('get', 'head'), # The content type map [Collections.IDictionary] $TypeMap = $( ([PSCustomObject]@{PSTypeName='OpenPackage.ContentTypeMap'}).TypeMap ), # A Route Table [Collections.IDictionary] $Route = [Ordered]@{}, # The throttle limit. # This is the number of concurrent jobs that can be running at once. [uint16]$ThrottleLimit = .5kb, # The buffer size. # If parts are smaller than this size, they will be streamed. # If parts are larger than this size, they will be handled in the background # (and may use a buffer of this size when accepting range requests) [uint]$BufferSize = 16mb, # The lifespan of the server. # If provided, will automatically stop the server after it's life is over. [TimeSpan]$Lifespan, # The number of nodes to run. # Each node can handle incoming requests. [byte]$NodeCount = 2 ) begin { # Requires Start-ThreadJob $startThreadJob = $ExecutionContext.SessionState.InvokeCommand.GetCommand('Start-ThreadJob', 'Cmdlet,Function') if (-not $startThreadJob) { Write-Error (@( "This feature requires Start-ThreadJob," "which is included in more recent versions of PowerShell." ) -join [Environment]::NewLine) return } $InitializationScript = { filter serverStatus { $errorCode = $_ $response.StatusCode = $errorCode foreach ($pack in $package) { if ($pack.PartExists("/$errorCode.html")) { "/$errorCode.html" | servePart continue nextRequest } if ($pack.PartExists("/$errorCode.md")) { "/$errorCode.md" | servePart continue nextRequest } } $response.Close() } filter findPart { foreach ($pack in $package) { try { if ($pack.PartExists($request.url.localPath)) { return $request.url.localPath } } catch { } } $potentialUris = if ($request.url.LocalPath -and $request.url.LocalPath -notmatch '\.[^\./]+?$') { if ($request.url.localPath -ne '/') { [IO.Packaging.PackUriHelper]::CreatePartUri($request.url.localPath) } if ($Route -and $route[$request.Url.LocalPath]) { [IO.Packaging.PackUriHelper]::CreatePartUri("$($route[$request.Url.LocalPath])") } $noTrailingSlash = ($request.url.LocalPath -replace '/$') if ($noTrailingSlash) { try { [IO.Packaging.PackUriHelper]::CreatePartUri($noTrailingSlash) } catch { Write-Warning "$_ - $($request.Url) - $($request.Url.LocalPath)" } } $noTrailingSlash + '/index.html' $noTrailingSlash + '/README.html' $noTrailingSlash + '/README.md' $noTrailingSlash + '/index.json' $noTrailingSlash + '/index.xml' $noTrailingSlash + '.html' $noTrailingSlash + '.md' } elseif ($request.url.LocalPath) { if ($Route -and $route[$request.Url.LocalPath]) { [IO.Packaging.PackUriHelper]::CreatePartUri("$($route[$request.Url.LocalPath])") } try { [IO.Packaging.PackUriHelper]::CreatePartUri($request.url.LocalPath) } catch { Write-Warning "$_ - $($request.Url) - $($request.Url.LocalPath)" } } else { @() } :nextPotential foreach ($potentialUri in $potentialUris) { :nextPack foreach ($pack in $package) { if ($pack.PartExists($potentialUri)) { return $potentialUri } :nextPart foreach ($part in $pack.GetParts()) { if ($part.Uri -eq $potentialUri) { return $part.Uri } } } } } # declare a little filter to serve a part filter servePart { $uriPart = $_ $packagePart = foreach ($pack in $package) { if ($pack.PartExists -and $pack.PartExists($uriPart)) { $pack.GetPart($uriPart) break } } if ($uriPart -match '\.[^.]+?$' -and $TypeMap[$matches.0] ) { $response.ContentType = $TypeMap[$matches.0] } else { $response.ContentType = $packagePart.ContentType } # If we are invokable and are dealing with a script file if ($Route -and @($route.Values) -contains $packagePart.Uri -and $packagePart.Uri -match '\.ps1$' -and $packagePart.Reader ) { if ($packagePart.Uri -match '\.ps1$') { $packageScript = $packagePart.Read() $packageParameterNames = [Ordered]@{} :nextParameter foreach ($packageScriptParameter in $packageScript.Ast.ParamBlock.Parameters) { if ($packageScriptParameter.Attributes.typename -match 'hidden') { continue nextParameter } $parameterName = "$($packageScriptParameter.Name.VariablePath.UserPath)" foreach ($attr in $packageScriptParameter.Attributes) { if ($attr.typename -ne 'alias') { continue } foreach ($parameterAlias in $attr.PositionalArguments.Value) { $packageParameterNames[$parameterAlias] = $parameterName } } $packageParameterNames[$parameterName] = $parameterName } $packageScriptParameters = [Ordered]@{} if ($request.Url.Query) { $parsedQuery = [Web.HttpUtility]::ParseQueryString($request.Url.Query) foreach ($queryKey in $parsedQuery.Keys) { if (-not $packageParameterNames[$queryKey]) { continue } $parameterName = $packageParameterNames[$queryKey] if ($null -eq $packageScriptParameters[$parameterName]) { $packageScriptParameters[$parameterName] = $parsedQuery[$queryKey] } else { $packageScriptParameters[$parameterName] = @( $packageScriptParameters[$parameterName] ) + $parsedQuery[$queryKey] } } } Write-Warning "Invoking $($packagePart.Uri) from $($request.Url)" $streamOutput = { param($reply) begin { if (-not $reply.OutputStream) { throw "no output stream" ; return } $reply.ProtocolVersion = '1.1' $reply.SendChunked = $true } process { $in = $_ if ($in.OuterXml) { $buffer = $OutputEncoding.GetBytes("$($in.OuterXml)") $reply.OutputStream.Write($buffer, 0, $buffer.Length) $reply.OutputStream.Flush() } elseif ($in.html) { $buffer = $OutputEncoding.GetBytes("$($in.html)") $reply.OutputStream.Write($buffer, 0, $buffer.Length) $reply.OutputStream.Flush() } else { # or the stringification of the result. $buffer = $OutputEncoding.GetBytes("$in") $reply.OutputStream.Write($buffer, 0, $buffer.Length) $reply.OutputStream.Flush() } } end { if ($reply.Close) { $reply.Close() } } } try { & $packageScript @packageScriptParameters | . $streamOutput $response } catch { $response.StatusCode = 500 if ($response.OutputStream.CanWrite) { $response.Close( [Text.Encoding]::UTF8.GetBytes("$_"), $false ) } else { $response.Close() } } } continue nextRequest } $acceptableTypes = @($request.Headers['Accept'] -split ',') Write-Host "Accepts $($request.Headers['Accept'])" -ForegroundColor Cyan if ( ( $packagePart.ContentType -eq 'text/markdown' -or $packagePart.Uri -match '(?>\.md|\.markdown)$' ) -and ( $acceptableTypes[0] -ne 'text/markdown' -and $request.Headers['Content-Type'] -ne 'text/markdown' ) ) { $response.ContentType = 'text/html' $response.Close([Text.Encoding]::UTF8.GetBytes("$( @( $packagePart | Format-OpenPackage -View Markdown.html ) -join [Environment]::NewLine )"), $false) return } $partStream = $packagePart.GetStream('Open', 'Read') Write-Host "$($request.HttpMethod) $uriPart $($response.ContentType)" -ForegroundColor Cyan if ($partStream.Length -lt $BufferSize) { $partStream.CopyTo($response.OutputStream) $partStream.Close() $partStream.Dispose() $response.Close() return } Start-ThreadJob -Name ($Request.Url -replace '^https?', 'part://') -ScriptBlock { param($partStream, $Request, $response, $BufferSize = 1mb) if (-not $partStream) { if ($response.Close) {$response.Close()} return } if ($request.Method -eq 'HEAD') { $response.ContentLength64 = $partStream.Length Write-Verbose "Serving HEAD request $($Request.url) - $partStreamLength" $partStream.Close() $null = $partStream.DisposeAsync() $response.Close() return } $response.Headers["Accept-Ranges"] = "bytes"; $range = $request.Headers['Range'] $rangeStart, $rangeEnd = -1, 0 if ($range) { $null = $range -match 'bytes=(?<Start>\d{1,})(-(?<End>\d{1,})){0,1}' $rangeStart, $rangeEnd = ($matches.Start -as [long]), ($matches.End -as [long]) } if ($rangeStart -ge 0 -and $rangeEnd -gt 0) { Write-Verbose -Verbose "Serving Request Range $($Request.url) : $($rangeStart)-$($rangeEnd)" $buffer = [byte[]]::new($BufferSize) $null = $partStream.Seek($rangeStart, 'Begin') $bytesRead = $partStream.Read($buffer, 0, $BufferSize) $contentRange = "$RangeStart-$($RangeStart + $bytesRead - 1)/$($partStream.Length)" $response.StatusCode = 206 $response.ContentLength64 = $bytesRead $response.Headers["Content-Range"] = $contentRange $response.OutputStream.Write($buffer, 0, $bytesRead) $response.OutputStream.Close() } else { Write-Verbose -Verbose "Serving Request without range $($Request.url)" # if that stream has a content length if ($partStream.Length -gt 0) { # set the content length $response.ContentLength64 = $partStream.Length } # Then copy the stream to the response. try { $partStream.CopyTo($response.OutputStream) } catch { Write-Warning "$_" } } $response.Close() $partStream.Close() $null = $partStream.DisposeAsync() } -ThrottleLimit 1kb -ArgumentList $partStream, $request, $response, $BufferSize return } } $JobDefinition = { param([Collections.IDictionary]$IO) # unpack our IO into local variables foreach ($variableName in $IO.Keys) { $ExecutionContext.SessionState.PSVariable.Set($variableName, $IO[$variableName]) } if ($ImportModule) { Write-Warning "Importing Modules $importModule" $imported = Import-Module -Name $ImportModule -PassThru Write-Warning "Imported Modules $imported" } # declare some inner functions to help serve $ServerStartTime = [DateTime]::Now # and start listening :nextRequest while ($httpListener.IsListening) { $getContextAsync = $httpListener.GetContextAsync() # wait in short increments to minimize CPU impact and stay snappy while (-not $getContextAsync.Wait(23)) { # while we're waiting, check our lifespan if ($Lifespan -and ( ($ServerStartTime + $Lifespan) -ge [DateTime]::Now )) { $httpListener.Stop() } } # If the counter is a long if ($IO.Counter -is [long]) { $IO.Counter += 1 # increment the counter. } # Get our listener context $context = $getContextAsync.Result # and break that into a result and response $request, $response = $context.Request, $context.Response if ($request.Url.LocalPath -eq '/favicon.ico') { $response.StatusCode = 404 $response.Close() continue nextRequest } $MessageData = [Ordered]@{ Url = $request.Url Context = $context Request = $request Response = $response Package = $package Handled = $false } if ($parentRunspace) { $requestEvent = $parentRunspace.Events.GenerateEvent( $request.Url.Scheme, $httpListener, @($request, $response, $context), $MessageData, $false, $true ) } # If the request has no output stream or it was handled by an event if (-not $response.OutputStream -or $MessageData.Handled) { # continue to the next request continue nextRequest } $requestTime = $requestEvent.TimeGenerated Write-Host -ForegroundColor Cyan "[$($requestTime.ToString('o'))] $($request.HttpMethod) $($request.Url)" # If they asked for an inappropriate method if ($request.HttpMethod -notin $Allow) { # use the appropriate status code Write-Host -ForegroundColor Red "[$($requestTime.ToString('o'))] 405 $($request.HttpMethod) $($request.Url)" 405 | serverStatus # and continue to the next request continue nextRequest } # If we're allowing additional methods, we can easily do CRUD operations switch -regex ($request.HttpMethod) { # Put or Post changes file content. '(?>put|post)' { $anythingChanged = $false $memoryStream = [IO.MemoryStream]::new() if ($request.InputStream.CanRead) { $request.InputStream.CopyTo($memoryStream) } :packageWrite foreach ($pack in $package) { if ($pack.FileOpenAccess -ne 'ReadWrite') { continue } $partStream = if ($pack.PartExists($request.Url.LocalPath)) { $pack.GetPart($request.Url.LocalPath).GetStream() } else { $newPart = $pack.CreatePart($request.Url.LocalPath, $request.ContentType, 'Superfast') $newPart.GetStream() } $null = $memoryStream.Seek(0, 'begin') $partStream.SetLength($memoryStream.Length) $memoryStream.CopyTo($partStream) $partStream.Close() $partStream.Dispose() $anythingChanged = $true break packageWrite } if ($anythingChanged -and $request.HttpMethod -eq 'put') { 201 | serverStatus continue nextRequest } } 'delete' { $anythingDeleted = $false :packageDelete foreach ($pack in $package) { if ($pack.FileOpenAccess -eq 'ReadWrite' -and $pack.PartExists( $request.Url.LocalPath )) { $pack.DeletePart($request.Url.LocalPath) $anythingDeleted = $true break packageDelete } } if ($anythingDeleted) { 204 | serverStatus continue nextRequest } } } $foundPart = . findPart if ($foundPart) { $foundPart | servePart continue nextRequest } $uriPart = ($request.Url.LocalPath -replace '/$') + '/' if ($uriPart -match '/$') { Write-Host -ForegroundColor Cyan "[$($requestTime.ToString('o'))] $($request.HttpMethod) $($request.Url) Missing index, generating" $response.ContentType = 'text/html' $response.Close( $OutputEncoding.GetBytes("$( $pack | Format-OpenPackage -View Tree.html -Option @{ FilePattern = [regex]::Escape($request.Url.LocalPath) } )"), $false ) continue nextRequest } else { Write-Host -ForegroundColor Cyan "[$($requestTime.ToString('o'))] Marco $($request.HttpMethod) $($request.Url)" } # If we did not find a part, set the appropriate status code 404 | serverStatus } } $generateEvent = [Runspace]::DefaultRunspace.Events.GenerateEvent } end { # Rapidly collect all pipeline input $allInput = @($input) # Get our packages # Each server can have any number of packages # The order packages are defined is the order they are resolved # This allows us to have any number of layers, in any order we want. $packages = @( # First up, lets process our input objects # (piped in objects come first) $remainingInput = @() foreach ($in in $allInput) { # Anything that is a package works if ($in -is [IO.Packaging.Package]) { $in } # so does anything that has a .Package property elseif ( $in.Package -is [IO.Packaging.Package] ) { $in.Package } # anything else we will pipe to Get-OpenPackage else { $remainingInput += $in } } # Now lets check a bound -InputObject # If piped in, this will potentially be a duplicated # (because `$InputObject` will contain the last bound value) foreach ($in in $InputObject) { # Skip any input we already have if ($allInput -contains $in) { continue } # If the -InputObject was a package if ($in -is [IO.Packaging.Package]) { $in # this works } # Otherwise, if the -InputObject has a .Package elseif ( $in.Package -is [IO.Packaging.Package] -and # and it is not a package we already have collected ($allInput.Package -notcontains $in.Package) ) { # then .Package works. $in.Package } # Otherwise, we will pipe remaining input to Get-OpenPackage elseif ($remainingInput -notcontains $in) { $remainingInput += $in } } # If there was remaining input if ($remainingInput) { # pipe it to Get-OpenPackage $remainingInput | Get-OpenPackage @ArgumentList } # If we had arguments, elseif ($ArgumentList) { # call Get-OpenPackage. Get-OpenPackage @ArgumentList } ) # Now we have a list of all of of potential packages # Let's make one last pass thru for safety and sanity $package = @( # and include only the packages foreach ($pack in $packages) { if ($pack -is [IO.Packaging.Package]) { $pack } } ) # If we have no actual packages, return. if (-not $package) { return } # Now that we know _what_ we're serving, # create a server by creating an http listener. $httpListener = [Net.HttpListener]::new() # and adding the root url prefix $httpListener.Prefixes.Add($RootUrl) # Create an IO object to populate the background runspace # By using an IO object, we can more easily communicate between runspaces. $IO = [Ordered]@{ HttpListener = $httpListener Package = $package ParentRunspace = [Runspace]::DefaultRunspace Counter = [long]0 } + $PSBoundParameters # If the IO does not have a typemap if (-not $io.TypeMap) { # copy the typemap $io.TypeMap = $TypeMap } # If the IO does not have an -Allow if (-not $io.Allow) { # use the default values for Allow. $io.Allow = $Allow } # If the IO does not have a bufferSize if (-not $io.BufferSize) { # use the default buffer size $io.BufferSize = $BufferSize } # If the IO does not have a route table if (-not $io.Route) { # use the default route table (this should be empty) $io.Route = $Route } # Now we're almost ready to serve, # it's time to send an event. # We need to prepare our message data with the relevant info $messageData = [Ordered]@{ RootUrl = $RootUrl Package = $package HttpListener = $httpListener InvocationInfo = $MyInvocation Command = $MyInvocation.MyCommand IO = $IO } # Generate an event $StartOpenPackageEvent = $generateEvent.Invoke( 'Start-OpenPackage', # for Start-OpenPackage $MyInvocation.MyCommand, # sent by this command @( # containing MyInvocation and MessageData $MyInvocation, $messageData ), # And sending the message data dictionary along $messageData, # process in the current thread $true, # and wait for completion. $true ) # If the event was processed, and they said any form of "no" if ($StartOpenPackageEvent.MessageData.Rejected -or $StartOpenPackageEvent.MessageData.Reject -or $StartOpenPackageEvent.MessageData.No -or $StartOpenPackageEvent.MessageData.Deny ) { Write-Warning "Will not $($MyInvocation.Line)" return } # Now let's start our listener try { $IO.HttpListener.Start() } catch { # if we could not, return $PSCmdlet.WriteError($_) return } # If that worked, if ($?) { # write a warning. # This serves two purposes: # 1. It lets people know that a server is running # 2. It gives people something a link to click. Write-Warning "Listening on $rootUrl" } # If there was no package identifier if (-not $package.PackageProperties.Identifier) { # write another warning. Write-Warning "No Package Identifier" } # Get ready to import our own module. $IO.ImportModule = @( if ($myInvocation.MyCommand.Module) { "$($MyInvocation.MyCommand.Module | Split-Path)" } else { Get-Module OP | Split-Path } ) # Remove the trailing slash from the root url # (prefixes require it, but it makes adding paths more annoying) $RootUrl = $RootUrl -replace '/$' # Prepare our parameters for Start-ThreadJob $JobParameters = [Ordered]@{ ScriptBlock=$JobDefinition ArgumentList=$IO Name=$RootUrl ThrottleLimit = $ThrottleLimit InitializationScript = $InitializationScript } foreach ($nodeNumber in 1..$NodeCount) { # Start a thread job and add our properties $startedJob = Start-ThreadJob @JobParameters | Add-Member NoteProperty IO $IO -Force -PassThru | Add-Member NoteProperty HttpListener $httpListener -Force -PassThru | Add-Member NoteProperty Package $package -Force -PassThru | Add-Member NoteProperty Url $RootUrl -Force -PassThru # Decorate our return $startedJob.pstypenames.add('OpenPackage.Server') # and output our server $startedJob } } } |