From f499ce7e4109601979975ce410b06fa14e372fd3 Mon Sep 17 00:00:00 2001 From: mdaneri Date: Mon, 25 Mar 2024 14:38:14 -0700 Subject: [PATCH 01/15] File Browsing feature for Pode Static Route #1237 Implementation of File Browser for static route + documentation --- docs/Tutorials/Configuration.md | 29 +- .../Routes/Utilities/StaticContent.md | 37 ++ examples/FileBrowser/FileBrowser.ps1 | 58 +++ examples/FileBrowser/public/ruler.png | Bin 0 -> 2708 bytes src/Misc/default-file-browsing.html.pode | 225 +++++++++ src/Pode.psd1 | 1 + src/Private/Helpers.ps1 | 48 +- src/Private/Middleware.ps1 | 32 +- src/Private/PodeServer.ps1 | 11 +- src/Private/Responses.ps1 | 459 ++++++++++++++++++ src/Private/Serverless.ps1 | 11 +- src/Public/Core.ps1 | 8 +- src/Public/Responses.ps1 | 157 +++--- src/Public/Routes.ps1 | 28 +- 14 files changed, 944 insertions(+), 160 deletions(-) create mode 100644 examples/FileBrowser/FileBrowser.ps1 create mode 100644 examples/FileBrowser/public/ruler.png create mode 100644 src/Misc/default-file-browsing.html.pode diff --git a/docs/Tutorials/Configuration.md b/docs/Tutorials/Configuration.md index 7e91cfa0b..6e38b2876 100644 --- a/docs/Tutorials/Configuration.md +++ b/docs/Tutorials/Configuration.md @@ -68,17 +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) | -| 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) | +| Server.RouteOrderMainBeforeStatic | 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 44da45d17..edfedc5f0 100644 --- a/docs/Tutorials/Routes/Utilities/StaticContent.md +++ b/docs/Tutorials/Routes/Utilities/StaticContent.md @@ -168,3 +168,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 `-Download,` 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 `Server.RouteOrderMainBeforeStatic` 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 0000000000000000000000000000000000000000..4dff9733927494feae7f8c7a1b3c521c6ae554ba GIT binary patch literal 2708 zcmbVOX;c&E8jcExED{84MU*iJ0-9v9lPCzuBob@bHz`#@GLQgSNTMVt3J3y%MJlzZ zaRaYH#0^1_MK%@CUdrNvP^7f7l%*ho3TVASrT6~m>5n_-%zWSbeed(U`#F>D<*~)s zz|sJLKp3;#As_7u&b|72+Hc^(=~V5qLct7D_)5YRv0NF9aN|qDV1O;+M!-HWm#>Ox zf;S-$i^7F|L5d*GHX2VN!f@wwFiMeBi$)+eZB|OTyeL=!guxL)u`_C{q8bGV`Oc_7 z5(nf+8L&X;9w&o+<2?L$aZx-fAGO&9*rcRs2}H1h3n)d=VmVFejQYq+)9&Yvu_)l9 ziXzGx^~tFqju*g?$Y6kk0nt2=3<6{v21g)LD3lEV9>jrI5Rb*-(Krf?fTw{V@cBV$ zt;zV|G#`ld*_KvvMhO%ODGiH_jg7^`5-<{31Qti7Qs+4Ec(hgnEmw&ZTqRm8x0z>v zU^!1Flq!T0F)+u-4U_CrIHRZYc-ne|+suQtx%+2BbBbDTXr3~mwkX_aNW$ACg2f6phszU+=j6Hae2Nx=Wy0MspCyxsfRFj53IE2v)?JWAfmn1mJVCbiw<& zU58z~c*f@>@$pTU$*^b*`N{oG$T!N?(ZAO40m`tf=6S?GTaVEVa1Zxc$~U#=kE+$T z)r42G>Yh!}F&h{VG>o89qQ|?v~oX%K_-{^09UHN)iC)?;O zM?&0{6F{mJG}V?G4tU*)Lt94V;3tQ;6BHlWK+7_{|vGWtUPTR zLfvEV&}w~489b|g3l*7IWu_oWbx~1I&N8ZBVKhTK6IQUCBq3+a&bXVW{w_UGGi%$MmGE??d3omM#EO4s*ZWcsC*uIEzD4Tm6ukwc!~3$rVKh?SLh z*6C$$Fl%sg#~~k-0^fNWIk@!hu8@wt+y3aPirJd1zJ9|vTY-THcXEIid4%EKeIxY4 zzcB}sbowllpISGT^E@}MJe-PL2b5%QjUeYG72mq2z65C=Ui8c>DgU5X9u?6KHn!6Wu*Fkl+xBy{{#;)1C&ZM%zu*p5EE<( zy;iC7HT(E?>+sj@!y-CkBGcGA(;!C|_x}7F_GVqp@`zu`yAI4e4JH&9^CH@}pH|tW zcnyQks_pd$hlg4A$eM{6i%>zLUj_G7(VC8D8qi;#u>*5x{SW6fH&-AVYbD)v)4ZMM z;=1o|ce{36dfpFk$}-8@dE{1}z@qN#K##|U%fjSDsGzG%SMQJqAu*I-(R|^67kxb} znC_g+JhiOb^2M_+YF@RZS9`oee8p|eB-V!G3n4dYNS$tW$n0w4qrB8wL$2bRDjm58 zMsxD(9Aj$y8S;n+*IPX=jD2?rlXS#ZvuSXaCdv`t>?5t(c-@N)f|7wDB; zb-D$@ko1<8f@1i$safAP=n3LGIwXYh?jaX{sNvZL8X3RNAO2OBShW43AdHq)v~Zi@ z8V=n)${-{CSiVhxXov3G`XTZKY#|k11*}HpAi}JYNkd*NLZI1YYlvObQ~hVsMBHLa zisV2_!0t)S5OqP?!9foMZXZT+ddY)4{l;_pkIRPOpsCyAo1~86tSzC<2%i+M$O+vZ zn_%F&E@SbZSR@p&qj~zw=jif^NP0)8ed3%A2_<7BFM?CS=# z_5N9pn5`A0P>NRceQI;t|GZP1Q-Wx-8F~xW4LNa<&Pc zdPn^1-d5l~6fbIRcvKWT{HxzZ)1eH?^8H65N=wl8`e3OH=N}xLm-&^B zce+g6va!oCceMeCyQ0mHW_Y9R{FMiSQ9n&ZqJ;>^-?h)FB&PRu@!t9?srm1&S6$T0 zxp4jO4hHbC*~e;v<7n%R_jp!Kw<@JCFJHQOSgRo{B*xrwLAduS?ah;XVipk;4H zR_4BP7r^!!1D8l0x9lyo3?t@S&#rd3aO(Bxg4TsscYGzJ#v~ux<{(2yzH@8IycPh4 vTaMl{$jW^K;uaAWm~Wi%b{Q{O{b31+pgz~U$zU + + + + + + 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 72fef9247..dfae68cd8 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/Helpers.ps1 b/src/Private/Helpers.ps1 index ecc38dc86..5ed49ee76 100644 --- a/src/Private/Helpers.ps1 +++ b/src/Private/Helpers.ps1 @@ -1619,23 +1619,6 @@ function Get-PodeCount { return $Object.Count } -function Test-PodePathAccess { - param( - [Parameter(Mandatory = $true)] - [string] - $Path - ) - - try { - $null = Get-Item $Path - } - catch [System.UnauthorizedAccessException] { - return $false - } - - return $true -} - function Test-PodePath { param( [Parameter()] @@ -1647,35 +1630,20 @@ function Test-PodePath { [switch] $FailOnDirectory ) - - # if the file doesnt exist then fail on 404 - if ([string]::IsNullOrWhiteSpace($Path) -or !(Test-Path $Path)) { - if (!$NoStatus) { - Set-PodeResponseStatus -Code 404 + if (![string]::IsNullOrWhiteSpace($Path)) { + $item = Get-Item $Path -ErrorAction Ignore + if ($null -ne $item -and (! $FailOnDirectory.IsPresent -or !$item.PSIsContainer)) { + return $true } - - return $false } - # if the file isn't accessible then fail 401 - if (!(Test-PodePathAccess $Path)) { - if (!$NoStatus) { - Set-PodeResponseStatus -Code 401 - } - + # if the file doesnt exist then fail on 404 + if ($NoStatus.IsPresent) { 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 - } - - return $false + else { + Set-PodeResponseStatus -Code 404 } - - return $true } function Test-PodePathIsFile { diff --git a/src/Private/Middleware.ps1 b/src/Private/Middleware.ps1 index 8db9d392e..24c7099cf 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 ($Server.Configuration.Server.RouteOrderMainBeforeStatic) { + # 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 40596694e..877aa091c 100644 --- a/src/Private/PodeServer.ps1 +++ b/src/Private/PodeServer.ps1 @@ -208,8 +208,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) @@ -217,11 +218,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 } } } @@ -472,6 +475,7 @@ function Start-PodeWebServer { function New-PodeListener { [CmdletBinding()] + [OutputType([Pode.PodeListener])] param( [Parameter(Mandatory = $true)] [System.Threading.CancellationToken] @@ -483,6 +487,7 @@ function New-PodeListener { function New-PodeListenerSocket { [CmdletBinding()] + [OutputType([Pode.PodeSocket])] param( [Parameter(Mandatory = $true)] [ipaddress] diff --git a/src/Private/Responses.ps1 b/src/Private/Responses.ps1 index e350c8d5a..2e0616ca9 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,429 @@ 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 = Get-Item -Path $Path -force -ErrorAction Continue + + # Check if the path exists + if ($null -eq $pathInfo) { + # If not, set the response status to 404 Not Found + Set-PodeResponseStatus -Code 404 + } + else { + # Check if the path is a directory + if ( $pathInfo.PSIsContainer) { + # If directory browsing is enabled, use the directory response function + if ($FileBrowser.isPresent) { + Write-PodeDirectoryResponseInternal -Path $Path + } + else { + # If browsing is not enabled, return a 404 error + Set-PodeResponseStatus -Code 404 + } + } + 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 = Get-PodeFileExtension -Path $Path -TrimPeriod + + # 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 = Get-PodeFileExtension -Path (Get-PodeFileName -Path $Path -WithoutExtension) -TrimPeriod + $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 { + if (Test-PodeIsPSCore) { + $content = (Get-Content -Path $Path -Raw -AsByteStream) + } + else { + $content = (Get-Content -Path $Path -Raw -Encoding byte) + } + if ($null -ne $content) { + # 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 + } + else { + # If the file does not exist, set the HTTP response status to 404 Not Found + Set-PodeResponseStatus -Code 404 + } + } + } + } +} + +<# +.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 + ) + + try { + 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 = 'bi bi-folder2' + } + else { + $size = '{0:N2}KB' -f ($item.Length / 1KB) + $icon = 'bi bi-file' + } + + # 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($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 + } + catch { + write-podehost $_ + } +} + + + +<# +.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 + + ) + + # resolve for relative path + $Path = Get-PodeRelativePath -Path $Path -JoinRoot + + # Attempt to retrieve information about the path + $pathInfo = Get-Item -Path $Path -force -ErrorAction Continue + # Check if the path exists + if ($null -eq $pathInfo) { + #if not exist try with to find with public Route if exist + $Path = Find-PodePublicRoute -Path $Path + if ($Path) { + # only attach files from public/static-route directories when path is relative + $Path = Get-PodeRelativePath -Path $Path -JoinRoot + # Attempt to retrieve information about the path + $pathInfo = Get-Item -Path $Path -ErrorAction Continue + } + if ($null -eq $pathInfo) { + # If not, set the response status to 404 Not Found + Set-PodeResponseStatus -Code 404 + return + } + } + if ( $pathInfo.PSIsContainer) { + # If directory browsing is enabled, use the directory response function + if ($FileBrowser.isPresent) { + Write-PodeDirectoryResponseInternal -Path $Path + return + } + else { + # If browsing is not enabled, return a 404 error + Set-PodeResponseStatus -Code 404 + 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 afe1aefc8..baf303eb7 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 6f91496da..a2f468090 100644 --- a/src/Public/Routes.ps1 +++ b/src/Public/Routes.ps1 @@ -476,6 +476,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. @@ -563,6 +566,9 @@ function Add-PodeStaticRoute { [switch] $DownloadOnly, + [switch] + $FileBrowser, + [switch] $PassThru, @@ -620,6 +626,10 @@ function Add-PodeStaticRoute { $DownloadOnly = $RouteGroup.DownloadOnly } + if ($RouteGroup.FileBrowser) { + $FileBrowser = $RouteGroup.FileBrowser + } + if ($RouteGroup.RedirectToDefault) { $RedirectToDefault = $RouteGroup.RedirectToDefault } @@ -757,12 +767,16 @@ function Add-PodeStaticRoute { # workout a default transfer encoding for the route $TransferEncoding = Find-PodeRouteTransferEncoding -Path $Path -TransferEncoding $TransferEncoding + #The path use KleeneStar(Asterisk) + $KleeneStar = $OrigPath.Contains('*') + # add the route(s) Write-Verbose "Adding Route: [$($Method)] $($Path)" $newRoutes = @(foreach ($_endpoint in $endpoints) { @{ Source = $Source Path = $Path + KleeneStar = $KleeneStar Method = $Method Defaults = $Defaults RedirectToDefault = $RedirectToDefault @@ -785,6 +799,8 @@ function Add-PodeStaticRoute { TransferEncoding = $TransferEncoding ErrorType = $ErrorContentType Download = $DownloadOnly + IsStatic = $true + FileBrowser = $FileBrowser.isPresent OpenApi = @{ Path = $OpenApiPath Responses = @{ @@ -795,7 +811,6 @@ function Add-PodeStaticRoute { RequestBody = @{} Authentication = @() } - IsStatic = $true Metrics = @{ Requests = @{ Total = 0 @@ -1237,6 +1252,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. @@ -1331,6 +1349,9 @@ function Add-PodeStaticRouteGroup { [switch] $AllowAnon, + [switch] + $FileBrowser, + [switch] $DownloadOnly, @@ -1399,6 +1420,10 @@ function Add-PodeStaticRouteGroup { $DownloadOnly = $RouteGroup.DownloadOnly } + if ($RouteGroup.FileBrowser) { + $FileBrowser = $RouteGroup.FileBrowser + } + if ($RouteGroup.RedirectToDefault) { $RedirectToDefault = $RouteGroup.RedirectToDefault } @@ -1442,6 +1467,7 @@ function Add-PodeStaticRouteGroup { Access = $Access AllowAnon = $AllowAnon DownloadOnly = $DownloadOnly + FileBrowser = $FileBrowser IfExists = $IfExists AccessMeta = @{ Role = $Role From c3ff9405df068b5d5797826695eddacf55f96432 Mon Sep 17 00:00:00 2001 From: mdaneri Date: Mon, 25 Mar 2024 15:19:45 -0700 Subject: [PATCH 02/15] Remove Write-PodeFileResponse test from Pester 4.x. The test is available for Pester 5.5 --- src/Private/Helpers.ps1 | 33 +++++++++++++++++++++++++++++++++ tests/unit/Responses.Tests.ps1 | 30 ------------------------------ 2 files changed, 33 insertions(+), 30 deletions(-) diff --git a/src/Private/Helpers.ps1 b/src/Private/Helpers.ps1 index 5ed49ee76..ae15606ad 100644 --- a/src/Private/Helpers.ps1 +++ b/src/Private/Helpers.ps1 @@ -1619,6 +1619,39 @@ function Get-PodeCount { return $Object.Count } +<# +.SYNOPSIS + Tests if a given file system path is valid and optionally if it is not a directory. + +.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. + +.EXAMPLE + $isValid = Test-PodePath -Path "C:\temp\file.txt" + if ($isValid) { + # The file exists and is not a directory + } + +.EXAMPLE + $isValid = Test-PodePath -Path "C:\temp\folder" -FailOnDirectory + if (!$isValid) { + # The path is a directory or does not exist + } + +.NOTES + This function is used within the Pode framework to validate file system paths for serving static content. + +#> + function Test-PodePath { param( [Parameter()] diff --git a/tests/unit/Responses.Tests.ps1 b/tests/unit/Responses.Tests.ps1 index 39f4c1ea0..8a0be2992 100644 --- a/tests/unit/Responses.Tests.ps1 +++ b/tests/unit/Responses.Tests.ps1 @@ -330,36 +330,6 @@ Describe 'Write-PodeTextResponse' { } } -Describe 'Write-PodeFileResponse' { - It 'Does nothing when the file does not exist' { - Mock Get-PodeRelativePath { return $Path } - Mock Test-PodePath { return $false } - Write-PodeFileResponse -Path './path' | Out-Null - Assert-MockCalled Test-PodePath -Times 1 -Scope It - } - - Mock Test-PodePath { return $true } - - It 'Loads the contents of a dynamic file' { - Mock Get-PodeRelativePath { return $Path } - Mock Get-PodeFileContentUsingViewEngine { return 'file contents' } - Mock Write-PodeTextResponse { return $Value } - - Write-PodeFileResponse -Path './path/file.pode' | Should Be 'file contents' - - Assert-MockCalled Get-PodeFileContentUsingViewEngine -Times 1 -Scope It - } - - It 'Loads the contents of a static file' { - Mock Get-PodeRelativePath { return $Path } - Mock Get-Content { return 'file contents' } - Mock Write-PodeTextResponse { return $Value } - - Write-PodeFileResponse -Path './path/file.pode' | Should Be 'file contents' - - Assert-MockCalled Get-PodeFileContentUsingViewEngine -Times 1 -Scope It - } -} Describe 'Use-PodePartialView' { $PodeContext = @{ From 9ab6286c622a42e23e68a08bbb04f0c1cb1b82ea Mon Sep 17 00:00:00 2001 From: mdaneri Date: Wed, 27 Mar 2024 16:04:23 -0700 Subject: [PATCH 03/15] Hide the get-item exception 'referred to an item that was outside the base' --- src/Private/Responses.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Private/Responses.ps1 b/src/Private/Responses.ps1 index 2e0616ca9..26835f10e 100644 --- a/src/Private/Responses.ps1 +++ b/src/Private/Responses.ps1 @@ -161,7 +161,7 @@ function Write-PodeFileResponseInternal { ) # Attempt to retrieve information about the path - $pathInfo = Get-Item -Path $Path -force -ErrorAction Continue + $pathInfo = Get-Item -Path $Path -force -ErrorAction SilentlyContinue # Check if the path exists if ($null -eq $pathInfo) { @@ -433,7 +433,7 @@ function Write-PodeAttachmentResponseInternal { $Path = Get-PodeRelativePath -Path $Path -JoinRoot # Attempt to retrieve information about the path - $pathInfo = Get-Item -Path $Path -force -ErrorAction Continue + $pathInfo = Get-Item -Path $Path -force -ErrorAction SilentlyContinue # Check if the path exists if ($null -eq $pathInfo) { #if not exist try with to find with public Route if exist @@ -442,7 +442,7 @@ function Write-PodeAttachmentResponseInternal { # only attach files from public/static-route directories when path is relative $Path = Get-PodeRelativePath -Path $Path -JoinRoot # Attempt to retrieve information about the path - $pathInfo = Get-Item -Path $Path -ErrorAction Continue + $pathInfo = Get-Item -Path $Path -ErrorAction SilentlyContinue } if ($null -eq $pathInfo) { # If not, set the response status to 404 Not Found From e27dd174188fa7b5f2207aab05650627114fcdda Mon Sep 17 00:00:00 2001 From: mdaneri Date: Mon, 1 Apr 2024 18:35:36 -0700 Subject: [PATCH 04/15] first review --- docs/Tutorials/Configuration.md | 2 +- .../Routes/Utilities/StaticContent.md | 4 +- src/Misc/default-file-browsing.html.pode | 4 +- src/Private/Middleware.ps1 | 2 +- src/Private/Responses.ps1 | 211 +++++++++--------- 5 files changed, 109 insertions(+), 114 deletions(-) diff --git a/docs/Tutorials/Configuration.md b/docs/Tutorials/Configuration.md index 6e38b2876..10fc3be88 100644 --- a/docs/Tutorials/Configuration.md +++ b/docs/Tutorials/Configuration.md @@ -77,7 +77,7 @@ A "path" like `Server.Ssl.Protocols` looks like the below in the file: | 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.RouteOrderMainBeforeStatic | Changes the way routes are processed. | [link](../Routes/Utilities/StaticContent) | +| Server.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) | diff --git a/docs/Tutorials/Routes/Utilities/StaticContent.md b/docs/Tutorials/Routes/Utilities/StaticContent.md index edfedc5f0..d05ef5413 100644 --- a/docs/Tutorials/Routes/Utilities/StaticContent.md +++ b/docs/Tutorials/Routes/Utilities/StaticContent.md @@ -181,7 +181,7 @@ Start-PodeServer -ScriptBlock { } ``` -When used with `-Download,` the browser downloads any file selected instead of rendering. The folders are rendered and not downloaded. +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. @@ -204,4 +204,4 @@ Nothing to report :D } ``` -To change the default behavior, you can use the `Server.RouteOrderMainBeforeStatic` 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 +To change the default behavior, you can use the `Server.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/src/Misc/default-file-browsing.html.pode b/src/Misc/default-file-browsing.html.pode index b24ef7b10..d2fb70df9 100644 --- a/src/Misc/default-file-browsing.html.pode +++ b/src/Misc/default-file-browsing.html.pode @@ -9,8 +9,8 @@