Pastas de imagens por usuário no ASP.NET MVC 5

No exemplo MVC 5, todos os usuários do editor compartilham uma única pasta uploads/ até você ativar uma chave em appSettings. Com ela ativada, as actions de galeria e de upload trabalham em uploads/users/<id>/ para o usuário autenticado e respondem a requisições anônimas com um JSON 401. Fazer esse 401 chegar ao navegador exige uma linha a mais do que você esperaria, e [Authorize] não é a ferramenta certa para isso. Esta página mostra por quê.
A chave e a raiz
<!-- true: each signed-in user gets uploads/users/<id>/ (see RteFiles.RootFor) -->
<add key="rte:PerUserFolders" value="false" />Tudo em RteFiles parte de um único método. O controller passa seu HttpContext (um HttpContextBase), e RootFor retorna a raiz relativa ao aplicativo para aquela requisição:
// 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();
}
}A listagem, a criação de pastas e o upload resolvem seus caminhos dentro dessa raiz, e as URLs das imagens são montadas a partir dela. Por isso, nada mais muda quando a chave está ativada. O nome da pasta é derivado de User.Identity.Name (16 caracteres hexadecimais do seu SHA-256), nunca usado do jeito que foi digitado. Se os seus usuários têm um id no banco de dados, retorne esse id.
Por que não [Authorize]
[Authorize] no RteController seria a forma óbvia de exigir autenticação. Com Forms authentication, ele não entrega um 401 à caixa de diálogo. Colocamos [Authorize] em uma action descartável e fizemos uma requisição anônima a ela. A resposta foi 302 para /Account/SignIn?ReturnUrl=%2fProbe%2fSecret, com ou sem o cabeçalho X-Requested-With: XMLHttpRequest. [Authorize] retorna um 401, e o FormsAuthenticationModule reescreve todo 401 como um redirecionamento para loginUrl. A requisição da caixa de diálogo segue o redirecionamento, recebe o HTML da página de login e não consegue interpretá-lo.
Por isso, RootFor lança RteException(401, …), e o helper de JSON do controller mantém o código de status:
// 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
};
}Com SuppressFormsAuthenticationRedirect = true, um GET /rte/gallery anônimo respondeu 401 {"error":"Sign in to use the image gallery."}. Os uploads chamam RootFor antes de ler o arquivo, então um upload anônimo recebe o mesmo 401.
Autenticação
RootFor lê apenas User, então ASP.NET Identity com cookies OWIN, Forms authentication e Windows authentication funcionam. O exemplo usa Forms authentication:
<!-- 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 é uma demonstração que autentica qualquer nome digitado, sem senha. Seu POST tem [ValidateAntiForgeryToken], e o controller inteiro responde 404 a menos que a chave esteja ativada. Substitua-o pelo seu login real. Se você usar autenticação por cookie OWIN, o redirecionamento que ela faz para os 401 exige o mesmo cuidado. Não testamos essa combinação.
O que testamos
Com a chave ativada, um script buscou o formulário de login, enviou-o com o token antiforgery para alice e para bob e usou os cookies .RTEDEMO de cada um:
- Os arquivos de alice foram para
/uploads/users/2bd806c97f0e00af/e os de bob para/uploads/users/81b637d8fcd2c6da/. - A pasta
tripsde alice apareceu na listagem dela, e não na de bob. folder=..gerou 400.folder=usersgerou 404, porque a raiz de um usuário é a própria pasta dele.- A URL da imagem de alice retornou 200 para uma requisição sem cookie.
Esse último resultado é esperado. /uploads/ é servido pelo IIS como arquivos estáticos, então qualquer pessoa com a URL pode carregar a imagem, que é o que um artigo publicado precisa. As pastas por usuário separam os usuários na caixa de diálogo. Elas não tornam os arquivos privados.
A imagem no topo é a pasta uploads do exemplo depois dos testes: as pastas compartilhadas de uma execução com a chave desativada e users/ com uma pasta por usuário.
Teste
No projeto de exemplo deste guia, defina rte:PerUserFolders como true e inicie o run.ps1 (porta 5402):
curl -i "http://localhost:5402/rte/gallery?folder="
HTTP/1.1 401 Unauthorized
{"error":"Sign in to use the image gallery."}Depois abra /Account/SignIn, faça login e abra Insert Gallery: ela começa na sua própria pasta vazia. Com a chave de volta em false, o teste de contrato passa em 35 de 35 verificações.
O mesmo guia para outras plataformas
JavaScript · PHP · ASP.NET Core · Web Forms