diff --git a/docs/Tutorials/Configuration.md b/docs/Tutorials/Configuration.md index d37c13df3..cccf6bfb9 100644 --- a/docs/Tutorials/Configuration.md +++ b/docs/Tutorials/Configuration.md @@ -68,18 +68,18 @@ A "path" like `Server.Ssl.Protocols` looks like the below in the file: } ``` -| Path | Description | Docs | -| --------------------- | ----------------------------------------------------------------------- | ------------------------------------------ | -| Server.Ssl.Protocols | Indicates the SSL Protocols that should be used | [link](../Certificates) | -| Server.Request | Defines request timeout and maximum body size | [link](../RequestLimits) | -| Server.AutoImport | Defines the AutoImport scoping rules for Modules, SnapIns and Functions | [link](../Scoping) | -| Server.Logging | Defines extra configuration for Logging, like masking sensitive data | [link](../Logging/Overview) | -| Server.Root | Overrides root path of the server | [link](../Misc/ServerRoot) | -| Server.Restart | Defines configuration for automatically restarting the server | [link](../Restarting/Types/AutoRestarting) | -| Server.FileMonitor | Defines configuration for restarting the server based on file updates | [link](../Restarting/Types/FileMonitoring) | -| Server.DefaultFolders | Define custom default folders | [link](../Routes/Utilities/StaticContent) | -| Web.TransferEncoding | Sets the Request TransferEncoding | [link](../Compression/Requests) | -| Web.Compression | Sets any compression to use on the Response | [link](../Compression/Responses) | -| Web.ContentType | Define expected Content Types for certain Routes | [link](../Routes/Utilities/ContentTypes) | -| Web.ErrorPages | Defines configuration for custom error pages | [link](../Routes/Utilities/ErrorPages) | -| Web.Static | Defines configuration for static content, such as caching | [link](../Routes/Utilities/StaticContent) | +| Path | Description | Docs | +| ------------------------------ | ----------------------------------------------------------------------- | ------------------------------------------ | +| Server.Ssl.Protocols | Indicates the SSL Protocols that should be used | [link](../Certificates) | +| Server.Request | Defines request timeout and maximum body size | [link](../RequestLimits) | +| Server.AutoImport | Defines the AutoImport scoping rules for Modules, SnapIns and Functions | [link](../Scoping) | +| Server.Logging | Defines extra configuration for Logging, like masking sensitive data | [link](../Logging/Overview) | +| Server.Root | Overrides root path of the server | [link](../Misc/ServerRoot) | +| Server.Restart | Defines configuration for automatically restarting the server | [link](../Restarting/Types/AutoRestarting) | +| Server.FileMonitor | Defines configuration for restarting the server based on file updates | [link](../Restarting/Types/FileMonitoring) | +| Web.Static.ValidateLast | Changes the way routes are processed. | [link](../Routes/Utilities/StaticContent) | +| Web.TransferEncoding | Sets the Request TransferEncoding | [link](../Compression/Requests) | +| Web.Compression | Sets any compression to use on the Response | [link](../Compression/Responses) | +| Web.ContentType | Define expected Content Types for certain Routes | [link](../Routes/Utilities/ContentTypes) | +| Web.ErrorPages | Defines configuration for custom error pages | [link](../Routes/Utilities/ErrorPages) | +| Web.Static | Defines configuration for static content, such as caching | [link](../Routes/Utilities/StaticContent) | diff --git a/docs/Tutorials/Routes/Utilities/StaticContent.md b/docs/Tutorials/Routes/Utilities/StaticContent.md index 69b83e1bb..2e1645396 100644 --- a/docs/Tutorials/Routes/Utilities/StaticContent.md +++ b/docs/Tutorials/Routes/Utilities/StaticContent.md @@ -200,3 +200,40 @@ Start-PodeServer { ``` When a static route is set as downloadable, then `-Defaults` and caching are not used. + +## File Browsing + +This feature allows the use of a static route as an HTML file browser. If you set the `-FileBrowser` switch on the [`Add-PodeStaticRoute`] function, the route will show the folder content whenever it is invoked. + +```powershell +Start-PodeServer -ScriptBlock { + Add-PodeEndpoint -Address localhost -Port 8080 -Protocol Http + Add-PodeStaticRoute -Path '/' -Source './content/assets' -FileBrowser + Add-PodeStaticRoute -Path '/download' -Source './content/newassets' -DownloadOnly -FileBrowser +} +``` + +When used with `-DownloadOnly`, the browser downloads any file selected instead of rendering. The folders are rendered and not downloaded. + +## Static Routes order +By default, Static routes are processed before any other route. +There are situations where you want a main `GET` route has the priority to a static one. +For example, you have to hide or make some computation to a file or a folder before returning the result. + +```powershell +Start-PodeServer -ScriptBlock { + Add-PodeRoute -Method Get -Path '/LICENSE.txt' -ScriptBlock { + $value = @' +Don't kidding me. Nobody will believe that you want to read this legalise nonsense. +I want to be kind; this is a summary of the content: + +Nothing to report :D +'@ + Write-PodeTextResponse -Value $value + } + + Add-PodeStaticRoute -Path '/' -Source "./content" -FileBrowser +} +``` + +To change the default behavior, you can use the `Web.Static.ValidateLast` property in the `server.psd1` configuration file, setting the value to `$True.` This will ensure that any static route is evaluated after any other route. \ No newline at end of file diff --git a/examples/FileBrowser/FileBrowser.ps1 b/examples/FileBrowser/FileBrowser.ps1 new file mode 100644 index 000000000..517f459e3 --- /dev/null +++ b/examples/FileBrowser/FileBrowser.ps1 @@ -0,0 +1,58 @@ +$FileBrowserPath = Split-Path -Parent -Path $MyInvocation.MyCommand.Path +$podePath = Split-Path -Parent -Path (Split-Path -Parent -Path $FileBrowserPath) +if (Test-Path -Path "$($podePath)/src/Pode.psm1" -PathType Leaf) { + Import-Module "$($podePath)/src/Pode.psm1" -Force -ErrorAction Stop +} +else { + Import-Module -Name 'Pode' +} + +$directoryPath = $podePath +# Start Pode server +Start-PodeServer -ScriptBlock { + + Add-PodeEndpoint -Address localhost -Port 8081 -Protocol Http -Default + + New-PodeLoggingMethod -Terminal | Enable-PodeRequestLogging + New-PodeLoggingMethod -Terminal | Enable-PodeErrorLogging + + # setup basic auth (base64> username:password in header) + New-PodeAuthScheme -Basic -Realm 'Pode Static Page' | Add-PodeAuth -Name 'Validate' -Sessionless -ScriptBlock { + param($username, $password) + + # here you'd check a real user storage, this is just for example + if ($username -eq 'morty' -and $password -eq 'pickle') { + return @{ + User = @{ + ID = 'M0R7Y302' + Name = 'Morty' + Type = 'Human' + } + } + } + + return @{ Message = 'Invalid details supplied' } + } + Add-PodeRoute -Method Get -Path '/LICENSE.txt' -ScriptBlock { + $value = @' +Don't kidding me. Nobody will believe that you want to read this legalise nonsense. +I want to be kind; this is a summary of the content: + +Nothing to report :D +'@ + Write-PodeTextResponse -Value $value + } + Add-PodeStaticRouteGroup -FileBrowser -Routes { + + Add-PodeStaticRoute -Path '/' -Source $using:directoryPath + Add-PodeStaticRoute -Path '/download' -Source $using:directoryPath -DownloadOnly + Add-PodeStaticRoute -Path '/nodownload' -Source $using:directoryPath + Add-PodeStaticRoute -Path '/any/*/test' -Source $using:directoryPath + Add-PodeStaticRoute -Path '/auth' -Source $using:directoryPath -Authentication 'Validate' + } + Add-PodeStaticRoute -Path '/nobrowsing' -Source $directoryPath + + Add-PodeRoute -Method Get -Path '/attachment/*/test' -ScriptBlock { + Set-PodeResponseAttachment -Path 'ruler.png' + } +} diff --git a/examples/FileBrowser/public/ruler.png b/examples/FileBrowser/public/ruler.png new file mode 100644 index 000000000..4dff97339 Binary files /dev/null and b/examples/FileBrowser/public/ruler.png differ diff --git a/src/Misc/default-file-browsing.html.pode b/src/Misc/default-file-browsing.html.pode new file mode 100644 index 000000000..f27780657 --- /dev/null +++ b/src/Misc/default-file-browsing.html.pode @@ -0,0 +1,221 @@ + + + + File Browser:$($Data.Path) + + + + + + + + + +
+
+
+ + + + $($Data.fileContent) +
+

+ ๐Ÿงก Powered by Pode +

+
+ + + + \ No newline at end of file diff --git a/src/Pode.psd1 b/src/Pode.psd1 index 048df39cd..c558cb9c2 100644 --- a/src/Pode.psd1 +++ b/src/Pode.psd1 @@ -77,6 +77,7 @@ 'Write-PodeJsonResponse', 'Write-PodeXmlResponse', 'Write-PodeViewResponse', + 'Write-PodeDirectoryResponse', 'Set-PodeResponseStatus', 'Move-PodeResponseUrl', 'Write-PodeTcpClient', diff --git a/src/Private/Context.ps1 b/src/Private/Context.ps1 index 699a4a9b7..d3494f469 100644 --- a/src/Private/Context.ps1 +++ b/src/Private/Context.ps1 @@ -856,6 +856,7 @@ function Set-PodeWebConfiguration { Include = (Convert-PodePathPatternsToRegex -Paths @($Configuration.Static.Cache.Include) -NotSlashes) Exclude = (Convert-PodePathPatternsToRegex -Paths @($Configuration.Static.Cache.Exclude) -NotSlashes) } + ValidateLast = [bool]$Configuration.Static.ValidateLast } ErrorPages = @{ ShowExceptions = [bool]$Configuration.ErrorPages.ShowExceptions diff --git a/src/Private/Helpers.ps1 b/src/Private/Helpers.ps1 index 6a2e9529f..a6896d155 100644 --- a/src/Private/Helpers.ps1 +++ b/src/Private/Helpers.ps1 @@ -1688,22 +1688,44 @@ function Get-PodeCount { return $Object.Count } -function Test-PodePathAccess { - param( - [Parameter(Mandatory = $true)] - [string] - $Path - ) +<# +.SYNOPSIS + Tests if a given file system path is valid and optionally if it is not a directory. - try { - $null = Get-Item $Path +.DESCRIPTION + This function tests if the provided file system path is valid. It checks if the path is not null or whitespace, and if the item at the path exists. If the item exists and is not a directory (unless the $FailOnDirectory switch is not used), it returns true. If the path is not valid, it can optionally set a 404 response status code. + +.PARAMETER Path + The file system path to test for validity. + +.PARAMETER NoStatus + A switch to suppress setting the 404 response status code if the path is not valid. + +.PARAMETER FailOnDirectory + A switch to indicate that the function should return false if the path is a directory. + +.PARAMETER Force + A switch to indicate that the file with the hidden attribute has to be includede + +.PARAMETER ReturnItem + Return the item file item itself instead of true or false + +.EXAMPLE + $isValid = Test-PodePath -Path "C:\temp\file.txt" + if ($isValid) { + # The file exists and is not a directory } - catch [System.UnauthorizedAccessException] { - return $false + +.EXAMPLE + $isValid = Test-PodePath -Path "C:\temp\folder" -FailOnDirectory + if (!$isValid) { + # The path is a directory or does not exist } - return $true -} +.NOTES + This function is used within the Pode framework to validate file system paths for serving static content. + +#> function Test-PodePath { param( @@ -1714,37 +1736,52 @@ function Test-PodePath { $NoStatus, [switch] - $FailOnDirectory - ) + $FailOnDirectory, - # if the file doesnt exist then fail on 404 - if ([string]::IsNullOrWhiteSpace($Path) -or !(Test-Path $Path)) { - if (!$NoStatus) { - Set-PodeResponseStatus -Code 404 - } + [switch] + $Force, - return $false - } + [switch] + $ReturnItem + ) - # if the file isn't accessible then fail 401 - if (!(Test-PodePathAccess $Path)) { - if (!$NoStatus) { - Set-PodeResponseStatus -Code 401 + $statusCode = 404 + + if (![string]::IsNullOrWhiteSpace($Path)) { + try { + $item = Get-Item $Path -Force:$Force -ErrorAction Stop + if (($null -ne $item) -and (!$FailOnDirectory -or !$item.PSIsContainer)) { + $statusCode = 200 + } + } + catch [System.Management.Automation.ItemNotFoundException] { + $statusCode = 404 + } + catch [System.UnauthorizedAccessException] { + $statusCode = 401 + } + catch { + $statusCode = 400 } - return $false } - # if we're failing on a directory then fail on 404 - if ($FailOnDirectory -and (Test-PodePathIsDirectory $Path)) { - if (!$NoStatus) { - Set-PodeResponseStatus -Code 404 + if ($statusCode -eq 200) { + if ($ReturnItem.IsPresent) { + return $item } + return $true + } - return $false + # if we failed to get the file, report back the status code and/or return true/false + if (!$NoStatus.IsPresent) { + Set-PodeResponseStatus -Code $statusCode } - return $true + if ($ReturnItem.IsPresent) { + return $null + } + return $false } function Test-PodePathIsFile { diff --git a/src/Private/Middleware.ps1 b/src/Private/Middleware.ps1 index 8db9d392e..24bbd4401 100644 --- a/src/Private/Middleware.ps1 +++ b/src/Private/Middleware.ps1 @@ -195,14 +195,40 @@ function Get-PodePublicMiddleware { }) } +<# +.SYNOPSIS + Middleware function to validate the route for an incoming web request. + +.DESCRIPTION + This function is used as middleware to validate the route for an incoming web request. It checks if the route exists for the requested method and path. If the route does not exist, it sets the appropriate response status code (404 for not found, 405 for method not allowed) and returns false to halt further processing. If the route exists, it sets various properties on the WebEvent object, such as parameters, content type, and transfer encoding, and returns true to continue processing. + +.PARAMETER None + +.EXAMPLE + $middleware = Get-PodeRouteValidateMiddleware + Add-PodeMiddleware -Middleware $middleware + +.NOTES + This function is part of the internal Pode server logic and is typically not called directly by users. + +#> function Get-PodeRouteValidateMiddleware { return @{ Name = '__pode_mw_route_validation__' Logic = { - # check if the path is static route first, then check the main routes - $route = Find-PodeStaticRoute -Path $WebEvent.Path -EndpointName $WebEvent.Endpoint.Name - if ($null -eq $route) { + if ($PodeContext.Server.Web.Static.ValidateLast) { + # check the main routes and check the static routes $route = Find-PodeRoute -Method $WebEvent.Method -Path $WebEvent.Path -EndpointName $WebEvent.Endpoint.Name -CheckWildMethod + if ($null -eq $route) { + $route = Find-PodeStaticRoute -Path $WebEvent.Path -EndpointName $WebEvent.Endpoint.Name + } + } + else { + # check if the path is static route first, then check the main routes + $route = Find-PodeStaticRoute -Path $WebEvent.Path -EndpointName $WebEvent.Endpoint.Name + if ($null -eq $route) { + $route = Find-PodeRoute -Method $WebEvent.Method -Path $WebEvent.Path -EndpointName $WebEvent.Endpoint.Name -CheckWildMethod + } } # if there's no route defined, it's a 404 - or a 405 if a route exists for any other method diff --git a/src/Private/PodeServer.ps1 b/src/Private/PodeServer.ps1 index 537527b10..032dcc3c5 100644 --- a/src/Private/PodeServer.ps1 +++ b/src/Private/PodeServer.ps1 @@ -213,8 +213,9 @@ function Start-PodeWebServer { # invoke the route if ($null -ne $WebEvent.StaticContent) { + $fileBrowser = $WebEvent.Route.FileBrowser if ($WebEvent.StaticContent.IsDownload) { - Set-PodeResponseAttachment -Path $WebEvent.Path -EndpointName $WebEvent.Endpoint.Name + Write-PodeAttachmentResponseInternal -Path $WebEvent.StaticContent.Source -FileBrowser:$fileBrowser } elseif ($WebEvent.StaticContent.RedirectToDefault) { $file = [System.IO.Path]::GetFileName($WebEvent.StaticContent.Source) @@ -222,11 +223,13 @@ function Start-PodeWebServer { } else { $cachable = $WebEvent.StaticContent.IsCachable - Write-PodeFileResponse -Path $WebEvent.StaticContent.Source -MaxAge $PodeContext.Server.Web.Static.Cache.MaxAge -Cache:$cachable + Write-PodeFileResponseInternal -Path $WebEvent.StaticContent.Source -MaxAge $PodeContext.Server.Web.Static.Cache.MaxAge ` + -Cache:$cachable -FileBrowser:$fileBrowser } } elseif ($null -ne $WebEvent.Route.Logic) { - $null = Invoke-PodeScriptBlock -ScriptBlock $WebEvent.Route.Logic -Arguments $WebEvent.Route.Arguments -UsingVariables $WebEvent.Route.UsingVariables -Scoped -Splat + $null = Invoke-PodeScriptBlock -ScriptBlock $WebEvent.Route.Logic -Arguments $WebEvent.Route.Arguments ` + -UsingVariables $WebEvent.Route.UsingVariables -Scoped -Splat } } } @@ -475,6 +478,7 @@ function Start-PodeWebServer { function New-PodeListener { [CmdletBinding()] + [OutputType([Pode.PodeListener])] param( [Parameter(Mandatory = $true)] [System.Threading.CancellationToken] @@ -486,6 +490,7 @@ function New-PodeListener { function New-PodeListenerSocket { [CmdletBinding()] + [OutputType([Pode.PodeSocket])] param( [Parameter(Mandatory = $true)] [string] diff --git a/src/Private/Responses.ps1 b/src/Private/Responses.ps1 index e350c8d5a..55cc9b516 100644 --- a/src/Private/Responses.ps1 +++ b/src/Private/Responses.ps1 @@ -1,3 +1,37 @@ +<# +.SYNOPSIS +Displays a customized error page based on the provided error code and additional error details. + +.DESCRIPTION +This function is responsible for displaying a custom error page when an error occurs within a Pode web application. It takes an error code, a description, an exception object, and a content type as input. The function then attempts to find a corresponding error page based on the error code and content type. If a custom error page is found, and if exception details are to be shown (as per server settings), it builds a detailed exception message. Finally, it writes the error page to the response stream, displaying the custom error page to the user. + +.PARAMETER Code +The HTTP status code of the error. This code is used to find a matching custom error page. + +.PARAMETER Description +A descriptive message about the error. This is displayed on the error page if available. + +.PARAMETER Exception +The exception object that caused the error. If exception tracing is enabled, details from this object are displayed on the error page. + +.PARAMETER ContentType +The content type of the error page to be displayed. This is used to select an appropriate error page format (e.g., HTML, JSON). + +.EXAMPLE +Show-PodeErrorPage -Code 404 -Description "Not Found" -ContentType "text/html" + +This example shows how to display a custom 404 Not Found error page in HTML format. + +.OUTPUTS +None. This function writes the error page directly to the response stream. + +.NOTES +- The function uses `Find-PodeErrorPage` to locate a custom error page based on the HTTP status code and content type. +- It checks for server configuration to determine whether to show detailed exception information on the error page. +- The function relies on the global `$PodeContext` variable for server settings and to encode exception and URL details safely. +- `Write-PodeFileResponse` is used to send the custom error page as the response, along with any dynamic data (e.g., exception details, URL). +- This is an internal function and may change in future releases of Pode. +#> function Show-PodeErrorPage { param( [Parameter()] @@ -48,4 +82,407 @@ function Show-PodeErrorPage { # write the error page to the stream Write-PodeFileResponse -Path $errorPage.Path -Data $data -ContentType $errorPage.ContentType +} + + + +<# +.SYNOPSIS +Serves files as HTTP responses in a Pode web server, handling both dynamic and static content. + +.DESCRIPTION +This function serves files from the server to the client, supporting both static files and files that are dynamically processed by a view engine. +For dynamic content, it uses the server's configured view engine to process the file and returns the rendered content. +For static content, it simply returns the file's content. The function allows for specifying content type, cache control, and HTTP status code. + +.PARAMETER Path +The relative path to the file to be served. This path is resolved against the server's root directory. + +.PARAMETER Data +A hashtable of data that can be passed to the view engine for dynamic files. + +.PARAMETER ContentType +The MIME type of the response. If not provided, it is inferred from the file extension. + +.PARAMETER MaxAge +The maximum age (in seconds) for which the response can be cached by the client. Applies only to static content. + +.PARAMETER StatusCode +The HTTP status code to accompany the response. Defaults to 200 (OK). + +.PARAMETER Cache +A switch to indicate whether the response should include HTTP caching headers. Applies only to static content. + +.EXAMPLE +Write-PodeFileResponseInternal -Path 'index.pode' -Data @{ Title = 'Home Page' } -ContentType 'text/html' + +Serves the 'index.pode' file as an HTTP response, processing it with the view engine and passing in a title for dynamic content rendering. + +.EXAMPLE +Write-PodeFileResponseInternal -Path 'logo.png' -ContentType 'image/png' -Cache + +Serves the 'logo.png' file as a static file with the specified content type and caching enabled. + +.OUTPUTS +None. The function writes directly to the HTTP response stream. + +.NOTES +This is an internal function and may change in future releases of Pode. +#> + +function Write-PodeFileResponseInternal { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [ValidateNotNull()] + [string] + $Path, + + [Parameter()] + $Data = @{}, + + [Parameter()] + [string] + $ContentType = $null, + + [Parameter()] + [int] + $MaxAge = 3600, + + [Parameter()] + [int] + $StatusCode = 200, + + [switch] + $Cache, + + [switch] + $FileBrowser + ) + + # Attempt to retrieve information about the path + $pathInfo = Test-PodePath -Path $Path -Force -ReturnItem -FailOnDirectory:(!$FileBrowser) + + if (!$pathinfo) { + return + } + + # Check if the path is a directory + if ( $pathInfo.PSIsContainer) { + # If directory browsing is enabled, use the directory response function + Write-PodeDirectoryResponseInternal -Path $Path + } + else { + # are we dealing with a dynamic file for the view engine? (ignore html) + # Determine if the file is dynamic and should be processed by the view engine + $mainExt = $pathInfo.Extension.TrimStart('.') + + # generate dynamic content + if (![string]::IsNullOrWhiteSpace($mainExt) -and ( + ($mainExt -ieq 'pode') -or + ($mainExt -ieq $PodeContext.Server.ViewEngine.Extension -and $PodeContext.Server.ViewEngine.IsDynamic) + ) + ) { + # Process dynamic content with the view engine + $content = Get-PodeFileContentUsingViewEngine -Path $Path -Data $Data + + # Determine the correct content type for the response + # get the sub-file extension, if empty, use original + $subExt = [System.IO.Path]::GetExtension($pathInfo.BaseName).TrimStart('.') + + $subExt = (Protect-PodeValue -Value $subExt -Default $mainExt) + + $ContentType = (Protect-PodeValue -Value $ContentType -Default (Get-PodeContentType -Extension $subExt)) + + # Write the processed content as the HTTP response + Write-PodeTextResponse -Value $content -ContentType $ContentType -StatusCode $StatusCode + } + # this is a static file + else { + try { + if (Test-PodeIsPSCore) { + $content = (Get-Content -Path $Path -Raw -AsByteStream) + } + else { + $content = (Get-Content -Path $Path -Raw -Encoding byte) + } + # Determine and set the content type for static files + $ContentType = Protect-PodeValue -Value $ContentType -Default (Get-PodeContentType -Extension $mainExt) + # Write the file content as the HTTP response + Write-PodeTextResponse -Bytes $content -ContentType $ContentType -MaxAge $MaxAge -StatusCode $StatusCode -Cache:$Cache + return + } + catch [System.UnauthorizedAccessException] { + $statusCode = 401 + } + catch { + $statusCode = 400 + } + # If the file does not exist, set the HTTP response status code appropriately + Set-PodeResponseStatus -Code $StatusCode + + } + } +} + +<# +.SYNOPSIS +Serves a directory listing as a web page. + +.DESCRIPTION +The Write-PodeDirectoryResponseInternal function generates an HTML response that lists the contents of a specified directory, +allowing for browsing of files and directories. It supports both Windows and Unix-like environments by adjusting the +display of file attributes accordingly. If the path is a directory, it generates a browsable HTML view; otherwise, it +serves the file directly. + +.PARAMETER Path +The relative path to the directory that should be displayed. This path is resolved and used to generate a list of contents. + + +.EXAMPLE +# resolve for relative path +$RelativePath = Get-PodeRelativePath -Path './static' -JoinRoot +Write-PodeDirectoryResponseInternal -Path './static' + +Generates and serves an HTML page that lists the contents of the './static' directory, allowing users to click through files and directories. + +.NOTES +This is an internal function and may change in future releases of Pode. +#> +function Write-PodeDirectoryResponseInternal { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [string] + $Path + ) + + if ($WebEvent.Path -eq '/') { + $leaf = '/' + $rootPath = '/' + } + else { + # get leaf of current physical path, and set root path + $leaf = ($Path.Split(':')[1] -split '[\\/]+') -join '/' + $rootPath = $WebEvent.Path -ireplace "$($leaf)$", '' + } + + # Determine if the server is running in Windows mode or is running a varsion that support Linux + # https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/get-childitem?view=powershell-7.4#example-10-output-for-non-windows-operating-systems + $windowsMode = ((Test-PodeIsWindows) -or ($PSVersionTable.PSVersion -lt [version]'7.1.0') ) + + # Construct the HTML content for the file browser view + $htmlContent = [System.Text.StringBuilder]::new() + + $atoms = $WebEvent.Path -split '/' + $atoms = @(foreach ($atom in $atoms) { + if (![string]::IsNullOrEmpty($atom)) { + [uri]::EscapeDataString($atom) + } + }) + if ([string]::IsNullOrWhiteSpace($atoms)) { + $baseLink = '' + } + else { + $baseLink = "/$($atoms -join '/')" + } + + # Handle navigation to the parent directory (..) + if ($leaf -ne '/') { + $LastSlash = $baseLink.LastIndexOf('/') + if ($LastSlash -eq -1) { + Set-PodeResponseStatus -Code 404 + return + } + $ParentLink = $baseLink.Substring(0, $LastSlash) + if ([string]::IsNullOrWhiteSpace($ParentLink)) { + $ParentLink = '/' + } + $item = Get-Item '..' + if ($windowsMode) { + $htmlContent.Append(" ") + $htmlContent.Append($item.Mode) + } + else { + $htmlContent.Append(" ") + $htmlContent.Append($item.UnixMode) + $htmlContent.Append(" ") + $htmlContent.Append($item.User) + $htmlContent.Append(" ") + $htmlContent.Append($item.Group) + } + $htmlContent.Append(" ") + $htmlContent.Append($item.CreationTime.ToString('yyyy-MM-dd HH:mm:ss')) + $htmlContent.Append(" ") + $htmlContent.Append($item.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss')) + $htmlContent.Append( " .. ") + } + # Retrieve the child items of the specified directory + $child = Get-ChildItem -Path $Path -Force + foreach ($item in $child) { + $link = "$baseLink/$([uri]::EscapeDataString($item.Name))" + if ($item.PSIsContainer) { + $size = '' + $icon = '๐Ÿ“' + } + else { + $size = '{0:N2}KB' -f ($item.Length / 1KB) + $icon = '๐Ÿ“„' + } + + # Format each item as an HTML row + if ($windowsMode) { + $htmlContent.Append(" ") + $htmlContent.Append($item.Mode) + } + else { + $htmlContent.Append(" ") + $htmlContent.Append($item.UnixMode) + $htmlContent.Append(" ") + $htmlContent.Append($item.User) + $htmlContent.Append(" ") + $htmlContent.Append($item.Group) + } + $htmlContent.Append(" ") + $htmlContent.Append($item.CreationTime.ToString('yyyy-MM-dd HH:mm:ss')) + $htmlContent.Append(" ") + $htmlContent.Append($item.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss')) + $htmlContent.Append(" ") + $htmlContent.Append( $size) + $htmlContent.Append( " ") + $htmlContent.Append( $icon) + $htmlContent.Append( " ") + $htmlContent.Append($item.Name ) + $htmlContent.AppendLine(' ' ) + } + + $Data = @{ + RootPath = $RootPath + Path = $leaf.Replace('\', '/') + WindowsMode = $windowsMode.ToString().ToLower() + FileContent = $htmlContent.ToString() # Convert the StringBuilder content to a string + } + + $podeRoot = Get-PodeModuleMiscPath + # Write the response + Write-PodeFileResponseInternal -Path ([System.IO.Path]::Combine($podeRoot, 'default-file-browsing.html.pode')) -Data $Data + +} + + + +<# +.SYNOPSIS +Sends a file as an attachment in the response, supporting both file streaming and directory browsing options. + +.DESCRIPTION +The Write-PodeAttachmentResponseInternal function is designed to handle HTTP responses for file downloads or directory browsing within a Pode web server. It resolves the given file or directory path, sets the appropriate content type, and configures the response to either download the file as an attachment or list the directory contents if browsing is enabled. The function supports both PowerShell Core and Windows PowerShell environments for file content retrieval. + +.PARAMETER Path +The path to the file or directory. This parameter is mandatory and accepts pipeline input. The function resolves relative paths based on the server's root directory. + +.PARAMETER ContentType +The MIME type of the file being served. This is validated against a pattern to ensure it's in the format 'type/subtype'. If not specified, the function attempts to determine the content type based on the file extension. + +.PARAMETER FileBrowser +A switch parameter that, when present, enables directory browsing. If the path points to a directory and this parameter is enabled, the function will list the directory's contents instead of returning a 404 error. + +.EXAMPLE +Write-PodeAttachmentResponseInternal -Path './files/document.pdf' -ContentType 'application/pdf' + +Serves the 'document.pdf' file with the 'application/pdf' MIME type as a downloadable attachment. + +.EXAMPLE +Write-PodeAttachmentResponseInternal -Path './files' -FileBrowser + +Lists the contents of the './files' directory if the FileBrowser switch is enabled; otherwise, returns a 404 error. + +.NOTES +- This function integrates with Pode's internal handling of HTTP responses, leveraging other Pode-specific functions like Get-PodeContentType and Set-PodeResponseStatus. It differentiates between streamed and serverless environments to optimize file delivery. +- This is an internal function and may change in future releases of Pode. +#> +function Write-PodeAttachmentResponseInternal { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] + $Path, + + [Parameter()] + [string] + $ContentType, + + [Parameter()] + [switch] + $FileBrowser + + ) + + # Attempt to retrieve information about the path + $pathInfo = Test-PodePath -Path $Path -Force -ReturnItem -FailOnDirectory:(!$FileBrowser) + + if (!$pathinfo) { + return + } + + # Check if the path exists + if ($null -eq $pathInfo) { + return + } + + if ( $pathInfo.PSIsContainer) { + # filebrowsing is enabled, use the directory response function + Write-PodeDirectoryResponseInternal -Path $Path + return + } + try { + # setup the content type and disposition + if (!$ContentType) { + $WebEvent.Response.ContentType = (Get-PodeContentType -Extension $pathInfo.Extension) + } + else { + $WebEvent.Response.ContentType = $ContentType + } + + Set-PodeHeader -Name 'Content-Disposition' -Value "attachment; filename=$($pathInfo.Name)" + + # if serverless, get the content raw and return + if (!$WebEvent.Streamed) { + if (Test-PodeIsPSCore) { + $content = (Get-Content -Path $Path -Raw -AsByteStream) + } + else { + $content = (Get-Content -Path $Path -Raw -Encoding byte) + } + + $WebEvent.Response.Body = $content + } + + # else if normal, stream the content back + else { + # setup the response details and headers + $WebEvent.Response.SendChunked = $false + + # set file as an attachment on the response + $buffer = [byte[]]::new(64 * 1024) + $read = 0 + + # open up the file as a stream + $fs = (Get-Item $Path).OpenRead() + $WebEvent.Response.ContentLength64 = $fs.Length + + while (($read = $fs.Read($buffer, 0, $buffer.Length)) -gt 0) { + $WebEvent.Response.OutputStream.Write($buffer, 0, $read) + } + } + } + finally { + Close-PodeDisposable -Disposable $fs + } + } \ No newline at end of file diff --git a/src/Private/Serverless.ps1 b/src/Private/Serverless.ps1 index 0ff62e4fa..a7805947c 100644 --- a/src/Private/Serverless.ps1 +++ b/src/Private/Serverless.ps1 @@ -83,8 +83,9 @@ function Start-PodeAzFuncServer { if ((Invoke-PodeMiddleware -Middleware $WebEvent.Route.Middleware)) { # invoke the route if ($null -ne $WebEvent.StaticContent) { + $fileBrowser = $WebEvent.Route.FileBrowser if ($WebEvent.StaticContent.IsDownload) { - Set-PodeResponseAttachment -Path $WebEvent.Path -EndpointName $WebEvent.Endpoint.Name + Write-PodeAttachmentResponseInternal -Path $WebEvent.StaticContent.Source -FileBrowser:$fileBrowser } elseif ($WebEvent.StaticContent.RedirectToDefault) { $file = [System.IO.Path]::GetFileName($WebEvent.StaticContent.Source) @@ -92,7 +93,7 @@ function Start-PodeAzFuncServer { } else { $cachable = $WebEvent.StaticContent.IsCachable - Write-PodeFileResponse -Path $WebEvent.StaticContent.Source -MaxAge $PodeContext.Server.Web.Static.Cache.MaxAge -Cache:$cachable + Write-PodeFileResponseInternal -Path $WebEvent.StaticContent.Source -MaxAge $PodeContext.Server.Web.Static.Cache.MaxAge -Cache:$cachable -FileBrowser:$fileBrowser } } else { @@ -196,8 +197,9 @@ function Start-PodeAwsLambdaServer { if ((Invoke-PodeMiddleware -Middleware $WebEvent.Route.Middleware)) { # invoke the route if ($null -ne $WebEvent.StaticContent) { + $fileBrowser = $WebEvent.Route.FileBrowser if ($WebEvent.StaticContent.IsDownload) { - Set-PodeResponseAttachment -Path $WebEvent.Path -EndpointName $WebEvent.Endpoint.Name + Write-PodeAttachmentResponseInternal -Path $WebEvent.StaticContent.Source -FileBrowser:$fileBrowser } elseif ($WebEvent.StaticContent.RedirectToDefault) { $file = [System.IO.Path]::GetFileName($WebEvent.StaticContent.Source) @@ -205,7 +207,8 @@ function Start-PodeAwsLambdaServer { } else { $cachable = $WebEvent.StaticContent.IsCachable - Write-PodeFileResponse -Path $WebEvent.StaticContent.Source -MaxAge $PodeContext.Server.Web.Static.Cache.MaxAge -Cache:$cachable + Write-PodeFileResponseInternal -Path $WebEvent.StaticContent.Source -MaxAge $PodeContext.Server.Web.Static.Cache.MaxAge ` + -Cache:$cachable -FileBrowser:$fileBrowser } } else { diff --git a/src/Public/Core.ps1 b/src/Public/Core.ps1 index 626753567..3eb4e4dbd 100644 --- a/src/Public/Core.ps1 +++ b/src/Public/Core.ps1 @@ -306,6 +306,9 @@ An array of default pages to display, such as 'index.html'. .PARAMETER DownloadOnly When supplied, all static content on this Route will be attached as downloads - rather than rendered. +.PARAMETER FileBrowser +When supplied, If the path is a folder, instead of returning 404, will return A browsable content of the directory. + .PARAMETER Browse Open the web server's default endpoint in your default browser. @@ -368,6 +371,9 @@ function Start-PodeStaticServer { [switch] $DownloadOnly, + [switch] + $FileBrowser, + [switch] $Browse ) @@ -390,7 +396,7 @@ function Start-PodeStaticServer { } # add the static route - Add-PodeStaticRoute -Path $Path -Source (Get-PodeServerPath) -Defaults $Defaults -DownloadOnly:$DownloadOnly + Add-PodeStaticRoute -Path $Path -Source (Get-PodeServerPath) -Defaults $Defaults -DownloadOnly:$DownloadOnly -FileBrowser:$FileBrowser } } diff --git a/src/Public/Responses.ps1 b/src/Public/Responses.ps1 index 8fff8c91a..97690d265 100644 --- a/src/Public/Responses.ps1 +++ b/src/Public/Responses.ps1 @@ -18,6 +18,9 @@ The supplied value must match the valid ContentType format, e.g. application/jso .PARAMETER EndpointName Optional EndpointName that the static route was creating under. +.PARAMETER FileBrowser +If the path is a folder, instead of returning 404, will return A browsable content of the directory. + .EXAMPLE Set-PodeResponseAttachment -Path 'downloads/installer.exe' @@ -33,9 +36,10 @@ Set-PodeResponseAttachment -Path './data.txt' -ContentType 'application/json' .EXAMPLE Set-PodeResponseAttachment -Path '/assets/data.txt' -EndpointName 'Example' #> + function Set-PodeResponseAttachment { [CmdletBinding()] - param( + param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [string] $Path, @@ -46,7 +50,11 @@ function Set-PodeResponseAttachment { [Parameter()] [string] - $EndpointName + $EndpointName, + + [switch] + $FileBrowser + ) # already sent? skip @@ -55,71 +63,19 @@ function Set-PodeResponseAttachment { } # only attach files from public/static-route directories when path is relative - $_path = (Find-PodeStaticRoute -Path $Path -CheckPublic -EndpointName $EndpointName).Content.Source + $route = (Find-PodeStaticRoute -Path $Path -CheckPublic -EndpointName $EndpointName) + if ($route) { + $_path = $route.Content.Source - # if there's no path, check the original path (in case it's literal/relative) - if (!(Test-PodePath $_path -NoStatus)) { - $Path = Get-PodeRelativePath -Path $Path -JoinRoot - - if (Test-PodePath $Path -NoStatus) { - $_path = $Path - } } - - # test the file path, and set status accordingly - if (!(Test-PodePath $_path)) { - return - } - - $filename = Get-PodeFileName -Path $_path - $ext = Get-PodeFileExtension -Path $_path -TrimPeriod - - try { - # setup the content type and disposition - if (!$ContentType) { - $WebEvent.Response.ContentType = (Get-PodeContentType -Extension $ext) - } - else { - $WebEvent.Response.ContentType = $ContentType - } - - Set-PodeHeader -Name 'Content-Disposition' -Value "attachment; filename=$($filename)" - - # if serverless, get the content raw and return - if (!$WebEvent.Streamed) { - if (Test-PodeIsPSCore) { - $content = (Get-Content -Path $_path -Raw -AsByteStream) - } - else { - $content = (Get-Content -Path $_path -Raw -Encoding byte) - } - - $WebEvent.Response.Body = $content - } - - # else if normal, stream the content back - else { - # setup the response details and headers - $WebEvent.Response.SendChunked = $false - - # set file as an attachment on the response - $buffer = [byte[]]::new(64 * 1024) - $read = 0 - - # open up the file as a stream - $fs = (Get-Item $_path).OpenRead() - $WebEvent.Response.ContentLength64 = $fs.Length - - while (($read = $fs.Read($buffer, 0, $buffer.Length)) -gt 0) { - $WebEvent.Response.OutputStream.Write($buffer, 0, $read) - } - } - } - finally { - Close-PodeDisposable -Disposable $fs + else { + $_path = Get-PodeRelativePath -Path $Path -JoinRoot } + #call internal Attachment function + Write-PodeAttachmentResponseInternal -Path $_path -ContentType $ContentType -FileBrowser:$fileBrowser } + <# .SYNOPSIS Writes a String or a Byte[] to the Response. @@ -364,6 +320,9 @@ The status code to set against the response. .PARAMETER Cache Should the file's content be cached by browsers, or not? +.PARAMETER FileBrowser +If the path is a folder, instead of returning 404, will return A browsable content of the directory. + .EXAMPLE Write-PodeFileResponse -Path 'C:/Files/Stuff.txt' @@ -378,6 +337,9 @@ Write-PodeFileResponse -Path 'C:/Views/Index.pode' -Data @{ Counter = 2 } .EXAMPLE Write-PodeFileResponse -Path 'C:/Files/Stuff.txt' -StatusCode 201 + +.EXAMPLE +Write-PodeFileResponse -Path 'C:/Files/' -FileBrowser #> function Write-PodeFileResponse { [CmdletBinding()] @@ -403,49 +365,56 @@ function Write-PodeFileResponse { $StatusCode = 200, [switch] - $Cache + $Cache, + + [switch] + $FileBrowser ) # resolve for relative path - $Path = Get-PodeRelativePath -Path $Path -JoinRoot + $RelativePath = Get-PodeRelativePath -Path $Path -JoinRoot - # test the file path, and set status accordingly - if (!(Test-PodePath $Path -FailOnDirectory)) { - return - } + Write-PodeFileResponseInternal -Path $RelativePath -Data $Data -ContentType $ContentType -MaxAge $MaxAge ` + -StatusCode $StatusCode -Cache:$Cache -FileBrowser:$FileBrowser +} - # are we dealing with a dynamic file for the view engine? (ignore html) - $mainExt = Get-PodeFileExtension -Path $Path -TrimPeriod +<# +.SYNOPSIS +Serves a directory listing as a web page. - # generate dynamic content - if (![string]::IsNullOrWhiteSpace($mainExt) -and ( - ($mainExt -ieq 'pode') -or - ($mainExt -ieq $PodeContext.Server.ViewEngine.Extension -and $PodeContext.Server.ViewEngine.IsDynamic) - )) { - $content = Get-PodeFileContentUsingViewEngine -Path $Path -Data $Data +.DESCRIPTION +The Write-PodeDirectoryResponse function generates an HTML response that lists the contents of a specified directory, +allowing for browsing of files and directories. It supports both Windows and Unix-like environments by adjusting the +display of file attributes accordingly. If the path is a directory, it generates a browsable HTML view; otherwise, it +serves the file directly. - # get the sub-file extension, if empty, use original - $subExt = Get-PodeFileExtension -Path (Get-PodeFileName -Path $Path -WithoutExtension) -TrimPeriod - $subExt = (Protect-PodeValue -Value $subExt -Default $mainExt) +.PARAMETER Path +The path to the directory that should be displayed. This path is resolved and used to generate a list of contents. - $ContentType = (Protect-PodeValue -Value $ContentType -Default (Get-PodeContentType -Extension $subExt)) - Write-PodeTextResponse -Value $content -ContentType $ContentType -StatusCode $StatusCode - } +.EXAMPLE +Write-PodeDirectoryResponse -Path './static' - # this is a static file - else { - if (Test-PodeIsPSCore) { - $content = (Get-Content -Path $Path -Raw -AsByteStream) - } - else { - $content = (Get-Content -Path $Path -Raw -Encoding byte) - } +Generates and serves an HTML page that lists the contents of the './static' directory, allowing users to click through files and directories. +#> +function Write-PodeDirectoryResponse { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [ValidateNotNull()] + [string] + $Path + ) + + # resolve for relative path + $RelativePath = Get-PodeRelativePath -Path $Path -JoinRoot - $ContentType = (Protect-PodeValue -Value $ContentType -Default (Get-PodeContentType -Extension $mainExt)) - Write-PodeTextResponse -Bytes $content -ContentType $ContentType -MaxAge $MaxAge -StatusCode $StatusCode -Cache:$Cache + if (Test-Path -Path $RelativePath -PathType Container) { + Write-PodeDirectoryResponseInternal -Path $RelativePath + } + else { + Set-PodeResponseStatus -Code 404 } } - <# .SYNOPSIS Writes CSV data to the Response. @@ -1247,7 +1216,7 @@ function Save-PodeRequestFile { foreach ($file in $files) { # if the path is a directory, add the filename $filePath = $Path - if (Test-PodePathIsDirectory -Path $filePath) { + if (Test-Path -Path $filePath -PathType Container) { $filePath = [System.IO.Path]::Combine($filePath, $file) } diff --git a/src/Public/Routes.ps1 b/src/Public/Routes.ps1 index 85570508a..d788071df 100644 --- a/src/Public/Routes.ps1 +++ b/src/Public/Routes.ps1 @@ -472,6 +472,9 @@ One or more optional Scopes that will be authorised to access this Route, when u .PARAMETER User One or more optional Users that will be authorised to access this Route, when using Authentication with an Access method. +.PARAMETER FileBrowser +If supplied, when the path is a folder, instead of returning 404, will return A browsable content of the directory. + .PARAMETER RedirectToDefault If supplied, the user will be redirected to the default page if found instead of the page being rendered as the folder path. @@ -559,6 +562,9 @@ function Add-PodeStaticRoute { [switch] $DownloadOnly, + [switch] + $FileBrowser, + [switch] $PassThru, @@ -616,6 +622,10 @@ function Add-PodeStaticRoute { $DownloadOnly = $RouteGroup.DownloadOnly } + if ($RouteGroup.FileBrowser) { + $FileBrowser = $RouteGroup.FileBrowser + } + if ($RouteGroup.RedirectToDefault) { $RedirectToDefault = $RouteGroup.RedirectToDefault } @@ -777,6 +787,8 @@ function Add-PodeStaticRoute { TransferEncoding = $TransferEncoding ErrorType = $ErrorContentType Download = $DownloadOnly + IsStatic = $true + FileBrowser = $FileBrowser.isPresent OpenApi = @{ Path = $OpenApiPath Responses = @{ @@ -787,7 +799,6 @@ function Add-PodeStaticRoute { RequestBody = @{} Authentication = @() } - IsStatic = $true Metrics = @{ Requests = @{ Total = 0 @@ -1225,6 +1236,9 @@ Specifies what action to take when a Static Route already exists. (Default: Defa .PARAMETER AllowAnon If supplied, the Static Routes will allow anonymous access for non-authenticated users. +.PARAMETER FileBrowser +When supplied, If the path is a folder, instead of returning 404, will return A browsable content of the directory. + .PARAMETER DownloadOnly When supplied, all static content on the Routes will be attached as downloads - rather than rendered. @@ -1319,6 +1333,9 @@ function Add-PodeStaticRouteGroup { [switch] $AllowAnon, + [switch] + $FileBrowser, + [switch] $DownloadOnly, @@ -1387,6 +1404,10 @@ function Add-PodeStaticRouteGroup { $DownloadOnly = $RouteGroup.DownloadOnly } + if ($RouteGroup.FileBrowser) { + $FileBrowser = $RouteGroup.FileBrowser + } + if ($RouteGroup.RedirectToDefault) { $RedirectToDefault = $RouteGroup.RedirectToDefault } @@ -1430,6 +1451,7 @@ function Add-PodeStaticRouteGroup { Access = $Access AllowAnon = $AllowAnon DownloadOnly = $DownloadOnly + FileBrowser = $FileBrowser IfExists = $IfExists AccessMeta = @{ Role = $Role diff --git a/tests/unit/Responses.Tests.ps1 b/tests/unit/Responses.Tests.ps1 index fb5dfef23..a0f828eee 100644 --- a/tests/unit/Responses.Tests.ps1 +++ b/tests/unit/Responses.Tests.ps1 @@ -359,7 +359,7 @@ Describe 'Write-PodeFileResponse' { It 'Loads the contents of a dynamic file' { - Mock Test-PodePath { return $true } + Mock Test-PodePath { return @{ PSIsContainer = $false ; extension = '.pode' } } Mock Get-PodeRelativePath { return $Path } Mock Get-PodeFileContentUsingViewEngine { return 'file contents' } Mock Write-PodeTextResponse { return $Value } @@ -372,7 +372,7 @@ Describe 'Write-PodeFileResponse' { It 'Loads the contents of a static file' { - Mock Test-PodePath { return $true } + Mock Test-PodePath { return @{ PSIsContainer = $false ; extension = '.pode' } } Mock Get-PodeRelativePath { return $Path } Mock Get-Content { return 'file contents' } Mock Get-PodeFileContentUsingViewEngine { return 'file contents' }