Carpetas de imágenes por usuario en ASP.NET MVC 5

En el ejemplo de MVC 5, todos los usuarios del editor comparten una misma carpeta uploads/ hasta que activas un interruptor de appSettings. Con él activado, las acciones de la galería y de carga trabajan en uploads/users/<id>/ para el usuario con sesión iniciada y responden a las solicitudes anónimas con un 401 en JSON. Hacer que ese 401 llegue al navegador requiere una línea más de lo que esperarías, y [Authorize] no es la herramienta adecuada. Esta página explica por qué.
El interruptor y la raíz
<!-- true: each signed-in user gets uploads/users/<id>/ (see RteFiles.RootFor) -->
<add key="rte:PerUserFolders" value="false" />Todo en RteFiles parte de un único método. El controlador pasa su HttpContext (un HttpContextBase), y RootFor devuelve la raíz relativa a la aplicación para esa solicitud:
// 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(HttpContextBase 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();
}
}El listado, la creación de carpetas y la carga resuelven sus rutas dentro de esa raíz, y las URL de las imágenes se construyen a partir de ella. Así que no cambia nada más cuando el interruptor está activado. El nombre de la carpeta se deriva de User.Identity.Name (16 caracteres hexadecimales de su SHA-256), nunca se toma tal cual. Si tus usuarios tienen un id de base de datos, devuelve ese en su lugar.
Por qué no [Authorize]
[Authorize] en RteController sería la forma obvia de exigir inicio de sesión. Con la autenticación Forms, no le da al cuadro de diálogo un 401. Pusimos [Authorize] en una acción desechable y la solicitamos de forma anónima. La respuesta fue un 302 a /Account/SignIn?ReturnUrl=%2fProbe%2fSecret, con o sin el encabezado X-Requested-With: XMLHttpRequest. [Authorize] devuelve un 401, y FormsAuthenticationModule reescribe cada 401 como una redirección a loginUrl. La solicitud del cuadro de diálogo la sigue, recibe el HTML de la página de inicio de sesión y no consigue analizarlo.
Por eso, RootFor lanza RteException(401, …), y la función auxiliar JSON del controlador conserva el estado:
// Json() refuses GET unless told otherwise (JsonRequestBehavior.DenyGet is the default)
// and caps output at 2,097,152 characters; both are lifted here. Property names are
// written as declared, so the anonymous objects in RteFiles produce camelCase JSON.
JsonResult JsonStatus(int status, object data)
{
Response.StatusCode = status;
// Without this, IIS replaces 4xx bodies with its own error page for remote users.
Response.TrySkipIisCustomErrors = true;
// Forms authentication would turn a 401 into a 302 to the login page; keep the 401.
Response.SuppressFormsAuthenticationRedirect = true;
Response.Cache.SetCacheability(HttpCacheability.NoCache);
Response.Cache.SetNoStore();
return new JsonResult
{
Data = data,
JsonRequestBehavior = JsonRequestBehavior.AllowGet,
MaxJsonLength = int.MaxValue
};
}Con SuppressFormsAuthenticationRedirect = true, un GET /rte/gallery anónimo respondió 401 {"error":"Sign in to use the image gallery."}. Las cargas llaman a RootFor antes de leer el archivo, así que una carga anónima recibe el mismo 401.
Iniciar sesión
RootFor solo lee User, así que funcionan ASP.NET Identity con cookies OWIN, la autenticación Forms y la autenticación de Windows. El ejemplo usa la autenticación Forms:
<!-- Any ASP.NET sign-in works (Forms, Windows, OWIN cookies): RootFor only reads
HttpContext.User. AccountController.SignIn is a demo stand-in for your real login. -->
<authentication mode="Forms">
<forms loginUrl="~/Account/SignIn" name=".RTEDEMO" />
</authentication>AccountController.SignIn es una demo que inicia sesión con cualquier nombre que se escriba, sin contraseña. Su POST lleva [ValidateAntiForgeryToken], y todo el controlador responde 404 a menos que el interruptor esté activado. Sustitúyelo por tu inicio de sesión real. Si usas en su lugar la autenticación con cookies de OWIN, su propia redirección para los 401 necesita el mismo cuidado. No probamos esa combinación.
Qué probamos
Con el interruptor activado, un script obtuvo el formulario de inicio de sesión, lo envió con el token antiforgery para alice y para bob, y usó sus cookies .RTEDEMO:
- Los archivos de alice fueron a
/uploads/users/2bd806c97f0e00af/y los de bob a/uploads/users/81b637d8fcd2c6da/. - La carpeta
tripsde alice apareció en su listado y no en el de bob. folder=..dio 400.folder=usersdio 404, porque la raíz de un usuario es su propia carpeta.- La URL de la imagen de alice devolvió 200 a una solicitud sin cookie.
Ese último resultado es el esperado. IIS sirve /uploads/ como archivos estáticos, así que cualquiera que tenga una URL puede cargar la imagen, que es lo que necesita un artículo publicado. Las carpetas por usuario separan a los usuarios en el cuadro de diálogo. No hacen privados los archivos.
La imagen del principio es la carpeta uploads del ejemplo después de las pruebas: las carpetas compartidas de una ejecución con el interruptor desactivado, y users/ con una carpeta por usuario.
Pruébalo
En el proyecto de ejemplo de esta guía, establece rte:PerUserFolders en true e inicia run.ps1 (puerto 5402):
curl -i "http://localhost:5402/rte/gallery?folder="
HTTP/1.1 401 Unauthorized
{"error":"Sign in to use the image gallery."}Después abre /Account/SignIn, inicia sesión y abre Insert Gallery: empieza en tu propia carpeta vacía. Con el interruptor de nuevo en false, la prueba de contrato pasa 35 de 35.
La misma guía para otras plataformas
JavaScript · PHP · ASP.NET Core · Web Forms