File: HtmlDirectoryFormatter.cs
Web Access
Project: src\src\Middleware\StaticFiles\src\Microsoft.AspNetCore.StaticFiles.csproj (Microsoft.AspNetCore.StaticFiles)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.FileProviders;
 
namespace Microsoft.AspNetCore.StaticFiles;
 
/// <summary>
/// Generates an HTML view for a directory.
/// </summary>
public class HtmlDirectoryFormatter : IDirectoryFormatter
{
    private const string TextHtmlUtf8 = "text/html; charset=utf-8";
 
    private readonly HtmlEncoder _htmlEncoder;
 
    /// <summary>
    /// Constructs the <see cref="HtmlDirectoryFormatter"/>.
    /// </summary>
    /// <param name="encoder">The character encoding representation to use.</param>
    public HtmlDirectoryFormatter(HtmlEncoder encoder)
    {
        ArgumentNullException.ThrowIfNull(encoder);
        _htmlEncoder = encoder;
    }
 
    /// <summary>
    /// Generates an HTML view for a directory.
    /// </summary>
    public virtual Task GenerateContentAsync(HttpContext context, IEnumerable<IFileInfo> contents)
    {
        ArgumentNullException.ThrowIfNull(context);
        ArgumentNullException.ThrowIfNull(contents);
 
        context.Response.ContentType = TextHtmlUtf8;
 
        if (HttpMethods.IsHead(context.Request.Method))
        {
            // HEAD, no response body
            return Task.CompletedTask;
        }
 
        PathString requestPath = context.Request.PathBase + context.Request.Path;
 
        var builder = new StringBuilder();
 
        builder.AppendFormat(
            CultureInfo.InvariantCulture,
@"<!DOCTYPE html>
<html lang=""{0}"">", CultureInfo.CurrentUICulture.TwoLetterISOLanguageName);
 
        builder.AppendFormat(
            CultureInfo.InvariantCulture,
@"
<head>
  <title>{0} {1}</title>", HtmlEncode(Resources.HtmlDir_IndexOf), HtmlEncode(requestPath.Value!));
 
        builder.Append(@"
  <style>
    body {
        font-family: ""Segoe UI"", ""Segoe WP"", ""Helvetica Neue"", 'RobotoRegular', sans-serif;
        font-size: 14px;}
    header h1 {
        font-family: ""Segoe UI Light"", ""Helvetica Neue"", 'RobotoLight', ""Segoe UI"", ""Segoe WP"", sans-serif;
        font-size: 28px;
        font-weight: 100;
        margin-top: 5px;
        margin-bottom: 0px;}
    #index {
        border-collapse: separate;
        border-spacing: 0;
        margin: 0 0 20px; }
    #index th {
        vertical-align: bottom;
        padding: 10px 5px 5px 5px;
        font-weight: 400;
        color: #a0a0a0;
        text-align: center; }
    #index td { padding: 3px 10px; }
    #index th, #index td {
        border-right: 1px #ddd solid;
        border-bottom: 1px #ddd solid;
        border-left: 1px transparent solid;
        border-top: 1px transparent solid;
        box-sizing: border-box; }
    #index th:last-child, #index td:last-child {
        border-right: 1px transparent solid; }
    #index td.length, td.modified { text-align:right; }
    a { color:#1ba1e2;text-decoration:none; }
    a:hover { color:#13709e;text-decoration:underline; }
  </style>
</head>
<body>
  <section id=""main"">");
        builder.AppendFormat(
            CultureInfo.InvariantCulture,
            @"
    <header><h1>{0} <a href=""/"">/</a>", HtmlEncode(Resources.HtmlDir_IndexOf));
 
        string cumulativePath = "/";
        foreach (var segment in requestPath.Value!.Split('/', StringSplitOptions.RemoveEmptyEntries))
        {
            cumulativePath = cumulativePath + segment + "/";
            builder.AppendFormat(
                CultureInfo.InvariantCulture,
                @"<a href=""{0}"">{1}/</a>",
                HtmlEncode(cumulativePath), HtmlEncode(segment));
        }
 
        builder.AppendFormat(
            CultureInfo.InvariantCulture,
@"</h1></header>
    <table id=""index"" summary=""{0}"">
    <thead>
      <tr><th abbr=""{1}"">{1}</th><th abbr=""{2}"">{2}</th><th abbr=""{3}"">{4}</th></tr>
    </thead>
    <tbody>",
        HtmlEncode(Resources.HtmlDir_TableSummary),
        HtmlEncode(Resources.HtmlDir_Name),
        HtmlEncode(Resources.HtmlDir_Size),
        HtmlEncode(Resources.HtmlDir_Modified),
        HtmlEncode(Resources.HtmlDir_LastModified));
 
        foreach (var subdir in contents.Where(info => info.IsDirectory))
        {
            // Collect directory metadata in a try...catch in case the file is deleted while we're getting the data.
            // The metadata is retrieved prior to calling AppendFormat so if it throws, we won't have written a row
            // to the table.
            try
            {
                builder.AppendFormat(
                    CultureInfo.InvariantCulture,
                    @"
      <tr class=""directory"">
        <td class=""name""><a href=""./{0}/"">{0}/</a></td>
        <td></td>
        <td class=""modified"">{1}</td>
      </tr>",
                    HtmlEncode(subdir.Name),
                    HtmlEncode(subdir.LastModified.ToString(CultureInfo.CurrentCulture)));
            }
            catch (DirectoryNotFoundException)
            {
                // The physical DirectoryInfo class doesn't appear to throw for either
                // of Name or LastWriteTimeUtc (which backs LastModified in the physical provider)
                // if the directory doesn't exist. However, we don't know what other providers might do.
 
                // Just skip this directory. It was deleted while we were enumerating.
            }
            catch (FileNotFoundException)
            {
                // The physical DirectoryInfo class doesn't appear to throw for either
                // of Name or LastWriteTimeUtc (which backs LastModified in the physical provider)
                // if the directory doesn't exist. However, we don't know what other providers might do.
 
                // Just skip this directory. It was deleted while we were enumerating.
            }
        }
 
        foreach (var file in contents.Where(info => !info.IsDirectory))
        {
            // Collect file metadata in a try...catch in case the file is deleted while we're getting the data.
            // The metadata is retrieved prior to calling AppendFormat so if it throws, we won't have written a row
            // to the table.
            try
            {
                builder.AppendFormat(
                    CultureInfo.InvariantCulture,
                    @"
      <tr class=""file"">
        <td class=""name""><a href=""./{0}"">{0}</a></td>
        <td class=""length"">{1}</td>
        <td class=""modified"">{2}</td>
      </tr>",
                    HtmlEncode(file.Name),
                    HtmlEncode(file.Length.ToString("n0", CultureInfo.CurrentCulture)),
                    HtmlEncode(file.LastModified.ToString(CultureInfo.CurrentCulture)));
            }
            catch (DirectoryNotFoundException)
            {
                // There doesn't appear to be a case where DirectoryNotFound is thrown in the physical provider,
                // but we don't know what other providers might do.
 
                // Just skip this file. It was deleted while we were enumerating.
            }
            catch (FileNotFoundException)
            {
                // Just skip this file. It was deleted while we were enumerating.
            }
        }
 
        builder.Append(@"
    </tbody>
    </table>
  </section>
</body>
</html>");
        string data = builder.ToString();
        byte[] bytes = Encoding.UTF8.GetBytes(data);
        context.Response.ContentLength = bytes.Length;
        return context.Response.Body.WriteAsync(bytes, 0, bytes.Length);
    }
 
    private string HtmlEncode(string body)
    {
        return _htmlEncoder.Encode(body);
    }
}