Carpetas de imágenes por usuario en ASP.NET Web Forms

La carpeta uploads del ejemplo de Web Forms con las carpetas compartidas blog, products y team y una carpeta users con dos carpetas por usuario

Con una única carpeta uploads/ compartida, cada usuario del editor ve en la galería las imágenes de todos los demás. El ejemplo de Web Forms cambia eso con un método, RteFiles.RootFor, y un interruptor de appSettings. Con el interruptor activado, cada usuario con sesión iniciada recibe uploads/users/<id>/ y las solicitudes anónimas reciben un 401. Esta página muestra el cambio, la redirección de la autenticación Forms que estropea el 401 y lo que no protegen las carpetas por usuario.

El interruptor

XML · web.config
<appSettings>
  <!-- true: each signed-in user gets uploads/users/<id>/ (see RteFiles.RootFor) -->
  <add key="rte:PerUserFolders" value="false" />
</appSettings>

Está desactivado de forma predeterminada, y así el ejemplo se comporta exactamente como en la guía de la galería. Se lee con WebConfigurationManager.AppSettings, como cualquier otro valor de appSettings.

RootFor

C# · App_Code/RteFiles.cs
// The storage root for this request, as an app-relative virtual path. Files are written
// to Server.MapPath(root) and served from the same URL, so it stays inside ~/uploads,
// which web.config locks down to static files only.
//
// Default: one shared folder. With <add key="rte:PerUserFolders" value="true" /> in
// web.config, every signed-in user gets ~/uploads/users/<id>/ and anonymous requests get
// 401. /uploads/... is still a public URL: per-user roots keep users out of each other's
// folders in the dialog, they do not make the files private.
public static string RootFor(HttpContext context)
{
    if (WebConfigurationManager.AppSettings["rte:PerUserFolders"] != "true")
        return "~/uploads";

    IPrincipal user = context.User;
    if (user == null || !user.Identity.IsAuthenticated)
        throw new RteException(401, "Sign in to use the image gallery.");
    return "~/uploads/users/" + FolderIdFor(user.Identity.Name);
}

// A folder name for a user: the same user always gets the same folder, and nothing the
// user typed reaches the disk path (a name like "DOMAIN\bob" or "a@b.com" is not a folder name).
// If your users have a numeric id in your database, return that instead.
static string FolderIdFor(string userName)
{
    using (SHA256 sha = SHA256.Create())
    {
        byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(userName.ToLowerInvariant()));
        return BitConverter.ToString(hash, 0, 8).Replace("-", "").ToLowerInvariant();
    }
}

RootFor devuelve una ruta virtual relativa a la aplicación. Todo lo demás se construye a partir de ella. Server.MapPath la convierte en la raíz de disco con la que se comprueba cada carpeta, y VirtualPathUtility.ToAbsolute la convierte en el prefijo de URL de cada imagen. Así, el listado, la creación de carpetas y la carga cambian a la vez. La carpeta se crea cuando el usuario abre la galería por primera vez.

El nombre de la carpeta viene de HttpContext.User, pero nunca tal cual. Un nombre de inicio de sesión como DOMAIN\bob o a@b.com no sirve como nombre de carpeta, y no es algo que deba ponerse en una ruta. El ejemplo usa los 16 primeros caracteres hexadecimales del SHA-256 del nombre en minúsculas. Si tus usuarios tienen un id numérico en tu base de datos, devuelve ese en su lugar.

RootFor solo lee context.User, así que funciona cualquier inicio de sesión de ASP.NET: autenticación Forms, autenticación de Windows o una cookie OWIN. El ejemplo usa la autenticación Forms con una página de demostración, SignIn.aspx, que inicia sesión con cualquier nombre que se escriba, sin contraseña. Esa página responde 404 a menos que el interruptor esté activado. No la despliegues.

XML · web.config
<!-- Any ASP.NET sign-in works (Forms, Windows, OWIN cookies): RootFor only reads
     HttpContext.User. SignIn.aspx is a demo stand-in for your real login page. -->
<authentication mode="Forms">
  <forms loginUrl="~/SignIn.aspx" name=".RTEDEMO" />
</authentication>

El 401 que se convirtió en un 302

La primera prueba con el interruptor activado no devolvió 401. Un GET /rte/gallery anónimo volvió como un 302 a /SignIn.aspx?ReturnUrl=%2frte%2fgallery…. FormsAuthenticationModule convierte cada 401 en una redirección a loginUrl. El XMLHttpRequest del cuadro de diálogo sigue la redirección sin avisar, recibe el HTML de la página de inicio de sesión e informa de un error. La solución es una línea en el escritor de JSON, después de la cual la misma solicitud respondió 401 {"error":"Sign in to use the image gallery."}:

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

Las cargas también consultan primero a RootFor, así que una carga anónima es un 401 y no un "No file was uploaded."

Qué probamos

Con el interruptor activado, un script inició sesión como alice y como bob a través de SignIn.aspx, conservó cada cookie .RTEDEMO y comprobó lo siguiente:

  • Las cargas de alice fueron a /uploads/users/2bd806c97f0e00af/… y las de bob a /uploads/users/81b637d8fcd2c6da/….
  • alice creó trips. Estaba en su listado y no en el de bob.
  • Para alice, folder=.. dio 400 y folder=users dio 404. Su raíz es su propia carpeta, así que no puede subir a users/.
  • La URL de la imagen de alice se cargó sin ninguna cookie, con estado 200.

El último punto es así por diseño. Los archivos de uploads/ son archivos estáticos que IIS sirve a cualquiera que tenga la URL, y una imagen de una página publicada tiene que cargarse para todos los lectores. Las raíces por usuario impiden que los usuarios entren en las carpetas de los demás desde el cuadro de diálogo. No hacen privados los archivos. Para archivos privados, sírvelos a través de un handler que compruebe el usuario y guárdalos fuera de la carpeta del sitio.

La imagen de arriba muestra uploads después de ambas ejecuciones. Las carpetas compartidas vienen de la ejecución con el interruptor desactivado, y users/ contiene una carpeta por cada usuario que inició sesión.

Pruébalo

En el proyecto de ejemplo de esta guía, establece rte:PerUserFolders en true e inicia run.ps1. Después:

Shell
curl -i "http://localhost:5401/rte/gallery?folder="
HTTP/1.1 401 Unauthorized
{"error":"Sign in to use the image gallery."}

Abre /SignIn.aspx e inicia sesión con cualquier nombre. La página de demostración indica quién eres, y la galería abre tu propia carpeta vacía dentro de uploads/users/. Vuelve a poner el interruptor en false y la prueba de contrato vuelve a pasar (35 de 35).

La misma guía para otras plataformas

JavaScript · PHP · ASP.NET Core · MVC 5

Para seguir leyendo