Paginated List of Files in ASP.NET MVC

I recently encountered a need to paginate a list of files and directories as some folders may contain hundreds of items and it isn’t very nice to dump hundreds of filenames into a table and call it good.

Building a Model

The FilesystemExplorer is the Model we will be handing our MVC views:

public class FilesystemExplorer
{
    public int CurrentPage { get; set; }

    public int TotalItems { get; set; }

    public int MaxItems { get; set; }

    public List<DirectoryInfo> Directories
    {
        get;
        private set;
    }

    public List<FileInfo> Files
    {
        get;
        private set;
    }

    public int Count
    {
        get
        {
            return this.Directories.Count + this.Files.Count;
        }
    }

    public int TotalPages
    {
        get
        {
            if (this.TotalItems > 0) {
                return (int)Math.Ceiling(this.TotalItems / (double)this.MaxItems);
            } else {
                return 0;
            }
        }
    }

    public bool HasNextPage
    {
        get
        {
            return (this.CurrentPage < this.TotalPages);
        }
    }

    public bool HasPreviousPage
    {
        get
        {
            return (this.CurrentPage > 1);
        }
    }

    public FilesystemExplorer()
    {
        this.Directories = new List<DirectoryInfo>();
        this.Files       = new List<FileInfo>();
        this.TotalItems  = 0;
    }
}

The class has some important statistics as well as some very helpful properties that will ease the pain when generating pagination links.

Filling in the Details

So this FilesystemExplorer class is great and all, but it doesn’t actually do any real work for us yet. We will remedy that with a Factory Method:

public static FilesystemExplorer Create(string path,
                                        int page = 1,
                                        int count = 25,
                                        Func<IEnumerable<FileSystemInfo>, IEnumerable<FileSystemInfo>> decorate = null)
{
    if (page < 1) {
        throw new ArgumentOutOfRangeException("The page must be greater than zero.");
    }

    if (count < 1) {
        throw new ArgumentOutOfRangeException("The count must be greater than zero.");
    }

    if (!Directory.Exists(path)) {
        throw new System.IO.DirectoryNotFoundException();
    }

    // Calculate some basic pagination variables.  Note that pages are zero-indexed within
    /// the class itself, but pagination expects it to begin at one.
    FilesystemExplorer oReturn = new FilesystemExplorer();
    int                iPage   = page - 1;
    int                iCount  = 0;
    int                iSkip   = iPage * count;
    int                iTotal  = count + iSkip;

    // Make sure we have a decorating function
    if (decorate == null) {
        decorate = d => d.OrderBy(f => f.Name);
    }

    DirectoryInfo oDirectory                = new DirectoryInfo(path);
    IEnumerable<FileSystemInfo> ieDirectory = oDirectory.EnumerateFileSystemInfos();

    // Tell the return object exactly how many items are in the directory.
    oReturn.TotalItems  = ieDirectory.Count();
    oReturn.MaxItems    = count;
    oReturn.CurrentPage = page;

    // Separate the results into directory and file enumerators
    IEnumerable<FileSystemInfo> ieDirectories = ieDirectory.Where(
        info => (info is DirectoryInfo)
    );

    IEnumerable<FileSystemInfo> ieFiles = ieDirectory.Where(
        info => (info is FileInfo)
    );

    foreach (FileSystemInfo info in decorate(ieDirectories)) {
        iCount++;

        if (iCount <= iSkip) {
            continue;
        }

        if (iCount > iTotal) {
            return oReturn;
        }

        oReturn.Directories.Add(info as DirectoryInfo);
    }

    foreach (FileSystemInfo info in decorate(ieFiles)) {
        iCount++;

        if (iCount <= iSkip) {
            continue;
        }

        if (iCount > iTotal) {
            return oReturn;
        }

        oReturn.Files.Add(info as FileInfo);
    }

    return oReturn;
}

This class looks like a mouthful, but it isn’t that bad when we break it down. The parameters are pretty obvious minus that last one: Func<IEnumerable<FileSystemInfo>, IEnumerable<FileSystemInfo>> decorate. I wanted to give the calling code some flexibility on how the filesystem should be queried, such as sorting by filename or access times. This parameter does just that by handing over the object just before we actually turn it into a list.

The first bit of code is some sanity checking to make sure the values passed in were legit. We also define a decorator if one wasn’t given to us. The next bit is where we create our IEnumerable<T> and get the count of files within the directory. Unfortunately, this does force an enumeration, so we lose quite a bit of performance.

Next, we turn our original IEnumerable<T> into two: one for files and one for directories. Windows presents a sorted list of directories before presenting any files in Explorer, so for user experience purposes, I chose to mimic that feature. We run into some inefficiencies because of this as we must loop twice to provide the same information, but sometimes usability trumps performance.

The logic behind the pagination is actually quite simple. Pick directories until we’ve either run out of directories, or we’ve reached our maximum count of returnable items. If we’ve run out of directories, pick files until we either run out or reach our cap.

A Note On Security

Before we get into the usage of this code, it is necessary to point out some security principals that can save you some major embarrasment if heeded. You should never provide directory listings without careful thought and consideration. It is dangerous to give people this information as they will use it against you in any way they can. In addition, always perform input sanitization on anything coming from the user and/or the user’s browser! This includes making sure that the user cannot escape the specified root of your listing by using ..\ or the like. I’ve used code similar to this to provide listings on internal, pasword-protected sites to reduce attack surface, but never on a public facing site. It is recommended you do the same.

Usage

Now that my security rant is out of the way, let’s dive in and see how it all comes together:

public ActionResult Index(int page, int count, string directory)
{
    bool   bDirectory = false;
    string sBasePath  = Path.GetFullPath(Properties.Settings.Default.browsePath);
    string sFullPath  = directory.Trim(new char[] { '\\', '/' });

    // Combine the directory from our parameter with the base path.
    sFullPath = Path.GetFullPath(Path.Combine(sBasePath, sFullPath));

    // Make sure that the user isn't trying anything sneaky
    if (!sFullPath.StartsWith(sBasePath, true, CultureInfo.CurrentCulture)) {
        throw new HttpException(500, "Attempt to escape base path failed.");
    }

    // Sanity check to make sure we have a real directory
    if (Directory.Exists(sFullPath)) {
        bDirectory = true;
    }

    // Sanity check to make sure we have a real file.
    if (!bDirectory && !System.IO.File.Exists(sFullPath)) {
        throw new HttpException(404, "File Not Found");
    }

    // Order by write time in subdirectories, and use default ordering in base directory.
    FilesystemExplorer oExplorer;
    if (sFullPath == sBasePath) {
        oExplorer = FilesystemExplorer.Create(sFullPath, page, count);
    } else {
        oExplorer = FilesystemExplorer.Create(sFullPath, page, count, i => i.OrderByDescending(f => f.LastWriteTime));
    }

    // Give the view a model
    return View(oExplorer);
}

As you can see, the majority of this code is dedicated to making sure the user is not only acessing a valid directory, but making sure it is a subdirectory within our base path (or the base path itself). I made a setting, browsePath to keep things configurable. The object is passed to the view as a model, and likely be thrown into a table or list of some sort.

Routing

   routes.MapRoute(
    name: "Explorer",
    url: "browse/{page}/{count}/{*directory}",
    defaults: new { controller = "Explorer", action = "Index", page = 1, count = 25, directory = @"/" }
);

The route gives defaults for everything, including the directory so one can begin with simply /browse.

Pagination

I will be releasing an article soon about how to paginate this (and other) objects. Stay tuned!

As always, if you have any questions, please feel free to comment!