Pode icon indicating copy to clipboard operation
Pode copied to clipboard

File Browsing feature for Pode Static Route

Open mdaneri opened this issue 1 year ago • 11 comments

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
        }
    }
}

mdaneri avatar Feb 03 '24 00:02 mdaneri

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 the Get-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. Just Test-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 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 😃
  • 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

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.

Badgerati avatar Feb 03 '24 18:02 Badgerati

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 😛

Badgerati avatar Feb 03 '24 18:02 Badgerati

Don't want to bloat the thread too much, but out of curiousity, what's the reason for the bracket indent change?

RobinBeismann avatar Feb 03 '24 21:02 RobinBeismann

It's an issue with my visual studio settings

mdaneri avatar Feb 04 '24 12:02 mdaneri

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

mdaneri avatar Feb 05 '24 05:02 mdaneri

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

mdaneri avatar Feb 06 '24 00:02 mdaneri

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

mdaneri avatar Feb 06 '24 00:02 mdaneri

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
    }
}

mdaneri avatar Feb 11 '24 18:02 mdaneri

I think I solved the issue with the Defaults I modified Find-PodeStaticRoute and now is working as expected

mdaneri avatar Feb 11 '24 20:02 mdaneri

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.

Badgerati avatar Feb 13 '24 22:02 Badgerati

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'

mdaneri avatar Feb 13 '24 23:02 mdaneri

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!

Badgerati avatar Feb 21 '24 22:02 Badgerati

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

mdaneri avatar Feb 21 '24 22:02 mdaneri

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
}

Badgerati avatar Feb 24 '24 17:02 Badgerati

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 *

mdaneri avatar Feb 24 '24 20:02 mdaneri

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

mdaneri avatar Feb 25 '24 22:02 mdaneri

I forgot to update the thread. Now '/any/*/test/testing' works

mdaneri avatar Mar 16 '24 23:03 mdaneri