Adicionar uma galeria de imagens a um editor de texto rico no Web Forms

A caixa de diálogo Insert Gallery mostrando as pastas blog, products e team e quatro imagens do exemplo em Web Forms, uma delas selecionada

A caixa de diálogo Insert Gallery do RichTextEditor pode listar as imagens do seu servidor, abrir pastas e enviar arquivos para a pasta que está aberta. Ela precisa de um endpoint que responda com JSON. No ASP.NET Web Forms, esse endpoint é um IHttpHandler em App_Code. Esta página cobre o handler, o JSON que ele grava com o serializador que o .NET Framework já tem e dois comportamentos do IIS que quebram a galeria para visitantes remotos ou para arquivos .webp, enquanto tudo parece funcionar na sua máquina.

Ative a galeria

A opção galleryEndpoint aponta a caixa de diálogo para o servidor. maxUploadFileSize usa o mesmo limite do servidor, para que a caixa de diálogo possa recusar um arquivo grande demais antes de enviá-lo:

ASP.NET · Default.aspx
<script>
    var editor = new RichTextEditor("#editor", {
        toolbar: "full",
        galleryEndpoint: "<%= ResolveUrl("~/rte/gallery") %>",
        maxUploadFileSize: 4000000   // same limit as the server
    });
</script>

A caixa de diálogo envia GET /rte/gallery?folder=blog e espera as subpastas e as imagens da pasta. O caminho é mapeado para o handler no web.config, da mesma forma que a rota de upload em upload de imagens no Web Forms.

O handler

C# · App_Code/RteGalleryHandler.cs
public void ProcessRequest(HttpContext context)
{
    try
    {
        // Request.Unvalidated: a value such as "<b" would otherwise make ASP.NET request
        // validation throw (HTTP 500) before our own rules can answer with a 400.
        if (context.Request.HttpMethod == "GET")
        {
            string folder = context.Request.Unvalidated.QueryString["folder"];
            RteFiles.WriteJson(context, 200, RteFiles.Listing(context, folder));
        }
        else if (context.Request.HttpMethod == "POST")
        {
            string action = context.Request.Unvalidated.Form["action"];
            string folder = context.Request.Unvalidated.Form["folder"] ?? "";
            if (action != "create-folder")
                throw new RteException(400, "Unknown action.");
            RteFiles.CreateFolder(context, folder, context.Request.Unvalidated.Form["name"]);
            RteFiles.WriteJson(context, 200, RteFiles.Listing(context, folder));
        }
        else
        {
            context.Response.AppendHeader("Allow", "GET, POST");
            throw new RteException(405, "Use GET or POST.");
        }
    }
    catch (RteException ex)
    {
        RteFiles.WriteError(context, ex.Status, ex.Message);
    }
}

Ele lê Request.Unvalidated.QueryString, e não Request.QueryString. A validação de requisições do ASP.NET também se aplica a generic handlers. Com folder=<b>, a coleção validada lançou "A potentially dangerous Request.QueryString value was detected" e a caixa de diálogo recebeu um HTML 500. A leitura sem validação permite que as próprias regras de pasta do handler respondam com 400 {"error":"Invalid folder."}.

A listagem

C# · App_Code/RteFiles.cs
public static object Listing(HttpContext context, string folder)
{
    folder = folder ?? "";
    StorageRoot root = GetRoot(context);
    string dir = ResolveFolder(root, folder);

    List<string> folderNames = new List<string>();
    foreach (string path in Directory.GetDirectories(dir))
    {
        string name = Path.GetFileName(path);
        // Skip anything the dialog could not open again (hidden, odd names).
        if (IsValidName(name) && (File.GetAttributes(path) & FileAttributes.Hidden) == 0)
            folderNames.Add(name);
    }
    folderNames.Sort(CompareNames);

    List<FileInfo> files = new List<FileInfo>();
    foreach (FileInfo file in new DirectoryInfo(dir).GetFiles())
    {
        if (Array.IndexOf(ImageExtensions, file.Extension.ToLowerInvariant()) >= 0
            && (file.Attributes & FileAttributes.Hidden) == 0)
            files.Add(file);
    }
    files.Sort(delegate (FileInfo a, FileInfo b) { return CompareNames(a.Name, b.Name); });

    List<object> folders = new List<object>();
    foreach (string name in folderNames)
        folders.Add(new { folder = Combine(folder, name), name = name });

    List<object> images = new List<object>();
    foreach (FileInfo file in files)
        images.Add(new { url = UrlFor(root, folder, file.Name), name = file.Name, folder = folder, size = file.Length });

    string parent = null;
    if (folder.Length > 0)
    {
        int slash = folder.LastIndexOf('/');
        parent = slash < 0 ? "" : folder.Substring(0, slash);
    }

    return new
    {
        currentFolder = folder,
        currentFolderDisplay = "/" + folder,
        parentFolder = parent,
        folders = folders,
        images = images
    };
}

Apenas arquivos .jpg, .jpeg, .png, .gif e .webp são listados, e as pastas e imagens são ordenadas por nome. A ordenação converte os dois nomes para minúsculas e depois os compara de forma ordinal, porque essa é a ordem que a comparação com toLowerCase() do JavaScript produz. StringComparer.OrdinalIgnoreCase converte para maiúsculas, o que coloca _ depois das letras em vez de antes.

A resposta JSON de GET /rte/gallery?folder=blog no exemplo Web Forms, com currentFolder, parentFolder, folders e duas das imagens
O que a caixa de diálogo recebe para a pasta blog.

JSON sem pacote NuGet

C# · App_Code/RteFiles.cs
public static void WriteJson(HttpContext context, int status, object value)
{
    HttpResponse response = context.Response;
    response.Clear();
    response.StatusCode = status;
    // Without this, IIS replaces 4xx/5xx bodies with its own error page when
    // <httpErrors errorMode="Custom"> is in effect (the default for remote users).
    response.TrySkipIisCustomErrors = true;
    // FormsAuthenticationModule turns every 401 into a 302 to the login page, which an
    // XMLHttpRequest follows silently and then fails to parse. Keep the 401.
    response.SuppressFormsAuthenticationRedirect = true;
    response.ContentType = "application/json";
    response.Cache.SetCacheability(HttpCacheability.NoCache);
    response.Cache.SetNoStore();
    // JavaScriptSerializer ships with .NET Framework (System.Web.Extensions) and writes
    // property names exactly as declared, so the anonymous objects above are camelCase.
    // Its default MaxJsonLength (2,097,152 chars) would fail a huge folder; lift it.
    JavaScriptSerializer serializer = new JavaScriptSerializer();
    serializer.MaxJsonLength = int.MaxValue;
    response.Write(serializer.Serialize(value));
}
  • JavaScriptSerializer vem com o .NET Framework em System.Web.Extensions, então o App_Code pode usá-lo sem uma pasta bin\. Ele grava os nomes das propriedades exatamente como foram declarados. Os objetos anônimos usam nomes em camelCase, então o JSON sai em camelCase sem nenhuma configuração. Ele grava null no parentFolder da raiz e escapa ' como \u0027, o que continua sendo JSON válido.
  • MaxJsonLength tem como padrão 2,097,152 caracteres, e a configuração jsonSerialization do web.config não se aplica a new JavaScriptSerializer(). Uma pasta com muitos milhares de imagens lançaria exceção, então o exemplo aumenta o limite.
  • TrySkipIisCustomErrors. O IIS Express usa por padrão errorMode="DetailedLocalOnly", então na sua própria máquina um JSON 400 chega intacto de qualquer forma. Definimos <httpErrors errorMode="Custom">, que é o que os visitantes remotos recebem. Um handler que definia o status 400 sem essa flag teve o corpo substituído pela página HTML "Bad Request" do IIS. Com a flag, o JSON chegou.
  • Sem cache. A listagem muda a cada upload, então a resposta diz ao navegador para não armazená-la.

.webp no IIS Express

O applicationhost.config do IIS Express não tem um tipo MIME para .webp. Um upload .webp seria armazenado e listado, mas sua URL responderia 404.3, então a miniatura ficaria vazia. O exemplo adiciona o tipo:

XML · web.config
<staticContent>
  <!-- IIS Express (and older IIS) has no MIME type for .webp, so it would answer 404.3. -->
  <remove fileExtension=".webp" />
  <mimeMap fileExtension=".webp" mimeType="image/webp" />
</staticContent>

Ele usa <remove> antes de <mimeMap> porque uma entrada duplicada em um servidor que já conhece .webp gera um erro de configuração 500.19.

Teste

Inicie o projeto de exemplo deste guia com run.ps1 (porta 5401). Crie uma pasta chamada team com o botão New folder da caixa de diálogo e depois consulte-a:

Shell
curl "http://localhost:5401/rte/gallery?folder=team"
{"currentFolder":"team","currentFolderDisplay":"/team","parentFolder":"","folders":[],"images":[]}

curl "http://localhost:5401/rte/gallery?folder=..%2F"
{"error":"Invalid folder."}

A primeira resposta é HTTP 200 e a segunda HTTP 400. Uma pasta que não existe gera 404. No navegador, clique em Insert Gallery na barra de ferramentas. A caixa de diálogo mostra as pastas e imagens de uploads/.

O mesmo guia para outras plataformas

JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · Blazor · MVC 5

Leia a seguir