Pode
Pode copied to clipboard
File Browsing feature for Pode Static Route
Describe the Feature
Pode today has no option that allows browsing a directory when a default page is not available.
The idea is to add a new -FileBrowser
parameter to Add-PodeStaticRoute
Parameter
-FileBrowser
When supplied, If the path is a folder, instead of returning 404, will return A browsable content of the directory.
Code Changes
function Write-PodeFileResponse {
[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
)
# resolve for relative path
$Path = Get-PodeRelativePath -Path $Path -JoinRoot
# test the file path, and set status accordingly
if (! $FileBrowser.isPresent -and !(Test-PodePath $Path -FailOnDirectory)) {
return
}
# are we dealing with a dynamic file for the view engine? (ignore html)
$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)
)) {
$content = Get-PodeFileContentUsingViewEngine -Path $Path -Data $Data
# 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-PodeTextResponse -Value $content -ContentType $ContentType -StatusCode $StatusCode
}
# this is a static file
else {
if ( Test-PodePathIsDirectory $Path) {
# If the path is a directory and FileBrowser switch is used, generate a browsable list of files/directories
$child = Get-ChildItem -Path $Path
$pathSplit = $Path.Split(':')
$leaf = $pathSplit[1]
$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()
if ($leaf -ne '\' -and $leaf -ne '/') {
$pathSegments = $leaf -split '[\\/]+'
$baseEncodedSegments = $pathSegments | ForEach-Object {
# Use [Uri]::EscapeDataString for encoding to ensure spaces are encoded as %20 and other special characters are properly encoded
[Uri]::EscapeDataString($_)
}
$baseLink = $baseEncodedSegments -join '/'
$Item = Get-Item '..'
$ParentLink = $baseLink.TrimEnd('/').Substring(0, $baseLink.TrimEnd('/').LastIndexOf('/') + 1)
# Format .. as an HTML row
if ($windowsMode) {
$htmlContent.AppendLine("<tr> <td class='mode'>$($item.Mode)</td> <td class='lastWriteTime'>$($item.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss'))</td> <td class='size'></td> <td class='name'><a href='$ParentLink'>..</a></td> </tr>")
} else {
$htmlContent.AppendLine("<tr> <td class='unixMode'>$($item.UnixMode)</td> <td class='user'>$($item.User)</td> <td class='group'>$($item.Group)</td> <td class='lastWriteTime'>$($item.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss'))</td> <td class='size'></td> <td class='name'><a href='$ParentLink'>..</a></td> </tr>")
}
} else {
$baseLink = ''
}
if (!$baselink.EndsWith('/')) {
$baselink = "$baselink/"
}
foreach ($item in $child) {
if ($item.PSIsContainer) {
$size = ''
} else {
$size = '{0:N2}KB' -f ($item.Length / 1KB)
}
$link = "$baseLink$([uri]::EscapeDataString($item.Name))"
# Format each item as an HTML row
if ($windowsMode) {
$htmlContent.AppendLine("<tr> <td class='mode'>$($item.Mode)</td> <td class='lastWriteTime'>$($item.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss'))</td> <td class='size'>$size</td> <td class='name'><a href='$link'>$($item.Name)</a></td> </tr>")
} else {
$htmlContent.AppendLine("<tr> <td class='unixMode'>$($item.UnixMode)</td> <td class='user'>$($item.User)</td> <td class='group'>$($item.Group)</td> <td class='lastWriteTime'>$($item.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss'))</td> <td class='size'>$size</td> <td class='name'><a href='$link'>$($item.Name)</a></td> </tr>")
}
}
$Data = @{
Path = $baseLink
windowsMode = $windowsMode.ToString().ToLower()
fileContent = $htmlContent.ToString() # Convert the StringBuilder content to a string
}
$podeRoot = Get-PodeModuleMiscPath
# Write the response
Write-PodeFileResponse -Path ([System.IO.Path]::Combine($podeRoot, 'default-file-browsing.html.pode')) -Data $Data
} else {
if (Test-PodeIsPSCore) {
$content = (Get-Content -Path $Path -Raw -AsByteStream)
} else {
$content = (Get-Content -Path $Path -Raw -Encoding byte)
}
$ContentType = (Protect-PodeValue -Value $ContentType -Default (Get-PodeContentType -Extension $mainExt))
Write-PodeTextResponse -Bytes $content -ContentType $ContentType -MaxAge $MaxAge -StatusCode $StatusCode -Cache:$Cache
}
}
}
Hey @mdaneri,
I had an idea about a similar feature a while ago, I can see this being useful :)
Few notes after reviewing the code:
- For the example, it might be a tad excessive to create 1,000 files/directories 😂 I'd say just create a some amount of fixed files/directories that can be committed, enough to showcase the feature.
- I'd recommend trying to do as much of the HTML rendering within the
.pode
file as possible - even doing theGet-ChildItem
etc. there to fill the table. - For the table, having the CreationDate could be useful as well. Possibly also using the 📁 and 📄 emojis in the table as well, to more easily spot a directory from a file.
- I'm not too sure about this line:
$windowsMode = ((Test-PodeIsWindows) -or ($PSVersionTable.PSVersion -lt [version]'7.1.0') )
, looking at where$windowsMode
is used, I have 7.4 installed but no "UnixMode" property. JustTest-PodeIsWindows
would suffice. - The call to
Get-ChildItem -Path $Path
should include-Force
, so it can show all files/directories - including hidden ones - The new directory logic in
Write-PodeFileResponse
might be best moved out to a newWrite-PodeDirectoryResponse
. I'm happy for-FileBrowser
to stay onWrite-PodeFileResponse
and for it to callWrite-PodeDirectoryResponse
; it's just this way we have a separate re-usable function for people to use for other more specific use-cases involving directory listing 😃 - The following functions would also need a
-FileBrowser
switch adding:-
Start-PodeStaticServer
-
Add-PodeStaticRouteGroup
-
- Similar to the change in
PodeServer.ps1
, the same change will be needed for AWS/Azure inServerless.ps1
If possible as well, are you able to change the reformatting triggered on your end to have else
, catch
, finally
, etc. on new lines, rather than the same line as a close bracer? Just for this PR onwards, not the currently open OpenAPI one; just as it will start to cause conflicts as Pode's formatting changes the format on newer commits.
Ha, I just spotted this is in the #1136 PR! 😂 To save duplicating the comments above in the PR, I'll just leave the comments here as the review; I thought there was going to be a new PR after #1136 was merged 😛
Don't want to bloat the thread too much, but out of curiousity, what's the reason for the bracket indent change?
It's an issue with my visual studio settings
For the example, it might be a tad excessive to create 1,000 files/directories 😂 I'd say just create a some amount of fixed files/directories that can be committed, enough to showcase the feature.
Now is optional
I'd recommend trying to do as much of the HTML rendering within the .pode file as possible - even doing the Get-ChildItem etc. there to fill the table. For the table, having the CreationDate could be helpful as well. Possibly also using the 📁 and 📄 emojis in the table as well, to more easily spot a directory from a file.
I'm going to change how the page is rendered. Maybe I'm going to write the data in JSON inside the HTML and I'll use tabulator.js to visualize the tab.
I'm not too sure about this line: $windowsMode = ((Test-PodeIsWindows) -or ($PSVersionTable.PSVersion -lt [version]'7.1.0') ), looking at where $windowsMode is used, I have 7.4 installed but no "UnixMode" property. Just Test-PodeIsWindows would suffice.
7.1 is the first version to support Unix file system attributes
The call to Get-ChildItem -Path $Path should include -Force so that it can show all files/directories - including hidden ones
I have issues accessing any directory with a starting . (dot) . Something to do with the way Pode manages the file extension. Same issue with files with no extension
The
new directory logic in Write-PodeFileResponse might be best moved out to a new Write-PodeDirectoryResponse. I'm happy for -FileBrowser to stay on Write-PodeFileResponse and for it to call Write-PodeDirectoryResponse; it's just this way we have a separate re-usable function for people to use for other more specific use-cases involving directory listing 😃 Done. I move the logic to a private function and created a new public function. I modified the Set-PodeResponseAttachment to include the -FileBrowser param.
The following functions would also need a -FileBrowser switch adding: Start-PodeStaticServer Add-PodeStaticRouteGroup Similar to the change in PodeServer.ps1, the same change will be needed for AWS/Azure in Serverless.ps1
Done
If possible as well, are you able to change the reformatting triggered on your end to have else, catch, finally, etc. on new lines, rather than the same line as a close bracer?
Done
There is an issue with -DownloadOnly parameter. When you mix and match multiple static routes, the -DowloadOnly setting is the value set by the last static route. The sample includes this case.
Add-PodeStaticRouteGroup -FileBrowser -Routes {
Add-PodeStaticRoute -Path '/download' -Source $directoryPath -DownloadOnly
Add-PodeStaticRoute -Path '/nodownload' -Source $directoryPath
Add-PodeStaticRoute -Path '/' -Source $directoryPath -DownloadOnly
}
nodownload path shouldn't have the download only setting but instead it has
I found the issue with the .folder Test-PodePathIsDirectory
function Test-PodePathIsDirectory {
param(
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]
$Path,
[switch]
$FailOnWildcard
)
if ($FailOnWildcard -and (Test-PodePathIsWildcard $Path)) {
return $false
}
return ([string]::IsNullOrWhiteSpace([System.IO.Path]::GetExtension($Path)))
}
why [System.IO.Path]::GetExtension() to check if a Path is a directory?
This check is failing for each folder that starts with a dot like .vscode
I changed to return (Test-Path -Path $path -PathType Container)
and everything seem to work
I was running the tests, and I saw this
Context 'Valid values' {
It 'Returns true for a directory' {
Test-PodePathIsDirectory -Path './some/path/folder' | Should -Be $true
}
It 'Returns false for a file' {
Test-PodePathIsDirectory -Path './some/path/file.txt' | Should -Be $false
}
It 'Returns false for a wildcard' {
Test-PodePathIsDirectory -Path './some/path/*' -FailOnWildcard | Should -Be $false
}
}
If these are the expected results, what I did breaks the code, but with the exclusion of the 3rd test (that passed), the first and second tests look a bit forced.
I can have a folder called file.txt
, and I can have a file called folder
I'm reviewing all these folder/file test functions, and I found multiple issues.
For example, Test-PodePathAccess
doesn't work as expected
get-item
never returns [System.UnauthorizedAccessException]
I'm rewriting Test-PodePath
to make it quicker and more reliable
function Test-PodePath {
param(
[Parameter()]
$Path,
[switch]
$NoStatus,
[switch]
$FailOnDirectory
)
if (![string]::IsNullOrWhiteSpace($Path)) {
$item = Get-Item $Path -ErrorAction Ignore
if ($null -ne $item -and (! $FailOnDirectory.IsPresent -or !$item.PSIsContainer)) {
return $true
}
}
# if the file doesnt exist then fail on 404
if ($NoStatus.IsPresent) {
return $false
}
else {
Set-PodeResponseStatus -Code 404
}
}
I think I solved the issue with the Defaults
I modified Find-PodeStaticRoute
and now is working as expected
I think I can see what the issue was with defaults and static content; when you have multiple defined it seems I missed a path to ::Combine
! 🙈
The new Path/RootPath logic scares me, and I think it might be a little overkill. In Get-PodeRouteUrl
we're always going to be within the context of a Route that matches the Path of request, so the path matching would/should always return true on the first item 🤔 so I'm thinking it's unneeded.
For Write-PodeDirectoryResponseInternal
, I feel that using $WebEvent.Path
and appending the sub-directory/file name would work the same in place of RootPath for building the HREF. For the case of ..
the $WebEvent.Path
could still be used, with a similar /
lookup trick to drop a section of the Path - which should be safe, as you're already guarding against the PSDrive for static content being at the root of the drive.
The changes to Test-PodePath
and the use of Get-Item
look all right to me 👍
For the -DownloadOnly
issue I'm not sure. Looking at the code I can't see why the /nodownload
route would have it set, unless it's the browser forcing a download on the file regardless 🤔 might need to test that one.
I tried without RootPath (I have to change the name to this variable), but you are going to have some security issues.
if you use the file browser sample without RootPath logic and you are accessing a file, for example, /dowload/LICENSE.TXT,
the file you get is '/LICENSE.TXT' because the way it was implemented was always selecting the first route.
you can imagine the security concerns related to a path change.
Anyway, if you want to try without RootPath, go back to my Sunday Commit. RootPath also solves the problem with any property attached to a static route. Try with your last Develop commit and the filebrowser.ps1 sample (without -FileBrowser param), and you will see the problem. Anything attached to `/' is applied to '/download' and '/nodownload'
I've an idea for this I'll try an test this weekend that might work, so LICENSE.txt isn't returned. Just trying to get Pode.Web v1.0.0 wrapped up atm!
So far with my last updates everything work as expected. I'm interested on your feedback on all these changes. I have also rebased the code to the last version of Develop. I had only few conflicts all of them related to pester. I'm on 5.5 and you are still using 4.x
So I do get both bugs on the develop branch, /download/LICENSE.txt
matches to the /
static route, and the -DownloadOnly
switch flagging on the wrong routes because of the same above issue.
However, if I switch the logic from the #1228 branch, which I need to merge actually, both issues disappear for me. Same if I switch to the PR branch actually, as the route ordering fix is in there as well, and if I remove the -Path
logic from Get-PodeRouteUrl
everything still works OK for me.
For Write-PodeDirectoryResponseInternal
, I had a play and tweaked it to use $WebEvent.Path
and seems to work without issue:
function Write-PodeDirectoryResponseInternal {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[ValidateNotNull()]
[string]
$Path
)
# 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)
}
})
$baseLink = "/$($atoms -join '/')"
# Handle navigation to the parent directory (..)
if ($leaf -ne '/') {
$ParentLink = $baseLink.Substring(0, $baseLink.LastIndexOf('/'))
$item = Get-Item '..'
if ($windowsMode) {
$htmlContent.AppendLine("<tr> <td class='mode'>$($item.Mode)</td> <td class='dateTime'>$($item.CreationTime.ToString('yyyy-MM-dd HH:mm:ss'))</td> <td class='dateTime'>$($item.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss'))</td> <td class='size'></td> <td class='icon'><i class='bi bi-folder2-open'></td> <td class='name'><a href='$ParentLink'>..</a></td> </tr>")
}
else {
$htmlContent.AppendLine("<tr> <td class='unixMode'>$($item.UnixMode)</td> <td class='user'>$($item.User)</td> <td class='group'>$($item.Group)</td> <td class='dateTime'>$($item.CreationTime.ToString('yyyy-MM-dd HH:mm:ss'))</td> <td class='dateTime'>$($item.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss'))</td> <td class='size'></td> <td class='icon'><i class='bi bi-folder'></td> <td class='name'><a href='$ParentLink'>..</a></td> </tr>")
}
}
# 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.AppendLine("<tr> <td class='mode'>$($item.Mode)</td> <td class='dateTime'>$($item.CreationTime.ToString('yyyy-MM-dd HH:mm:ss'))</td> <td class='dateTime'>$($item.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss'))</td> <td class='size'>$size</td> <td class='icon'><i class='$icon'></i></td> <td class='name'><a href='$link'>$($item.Name)</a></td> </tr>")
}
else {
$htmlContent.AppendLine("<tr> <td class='unixMode'>$($item.UnixMode)</td> <td class='user'>$($item.User)</td> <td class='group'>$($item.Group)</td> <td class='dateTime'>$($item.CreationTime.ToString('yyyy-MM-dd HH:mm:ss'))</td> <td class='dateTime'>$($item.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss'))</td> <td class='size'>$size</td> <td class='icon'><i class='$icon'></i></td> <td class='name'><a href='$link'>$($item.Name)</a></td> </tr>")
}
}
$Data = @{
RootPath = $RootPath
Path = $leaf.Replace('\', '/')
WindowsMode = $windowsMode.ToString().ToLower()
FileContent = $htmlContent.ToString() # Convert the StringBuilder content to a string
}
$podeRoot = Get-PodeModuleMiscPath
Write-PodeFileResponse -Path ([System.IO.Path]::Combine($podeRoot, 'default-file-browsing.html.pode')) -Data $Data
}
Can you please merge #1228 so I can try if all my test cases are working with this change My concern is with path containing *
Done. I rolled back some of the changes related to the route/pattern, and I merged your suggestions. everything works with the exception of the path that includes // For example with path '/any//test' '/any/*/test/testing' doesn't work
I forgot to update the thread. Now '/any/*/test/testing' works