Pastas de imagens por usuário no ASP.NET Web Forms

Com uma única pasta uploads/ compartilhada, cada usuário do editor vê as imagens de todos os outros usuários na galeria. O exemplo Web Forms muda isso com um método, RteFiles.RootFor, e uma chave em appSettings. Com a chave ativada, cada usuário autenticado recebe uploads/users/<id>/ e as requisições anônimas recebem 401. Esta página mostra a mudança, o redirecionamento do Forms authentication que quebra o 401 e o que as pastas por usuário não protegem.
A chave
<appSettings>
<!-- true: each signed-in user gets uploads/users/<id>/ (see RteFiles.RootFor) -->
<add key="rte:PerUserFolders" value="false" />
</appSettings>Ela vem desativada por padrão e, desativada, o exemplo se comporta exatamente como no guia da galeria. Ela é lida com WebConfigurationManager.AppSettings, como qualquer outro valor de appSettings.
RootFor
// 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 retorna um caminho virtual relativo ao aplicativo. Todo o resto se apoia nele. Server.MapPath o transforma na raiz em disco com a qual cada pasta é verificada, e VirtualPathUtility.ToAbsolute o transforma no prefixo de URL de cada imagem. Assim, a listagem, a criação de pastas e o upload mudam juntos. A pasta é criada quando o usuário abre a galeria pela primeira vez.
O nome da pasta vem de HttpContext.User, mas nunca do jeito que foi digitado. Um nome de login como DOMAIN\bob ou a@b.com não é um nome de pasta utilizável, nem algo que se deva colocar em um caminho. O exemplo usa os primeiros 16 caracteres hexadecimais do SHA-256 do nome em minúsculas. Se os seus usuários têm um id numérico no seu banco de dados, retorne esse id.
RootFor lê apenas context.User, então qualquer login do ASP.NET funciona: Forms authentication, Windows authentication ou um cookie OWIN. O exemplo usa Forms authentication com uma página de demonstração, SignIn.aspx, que autentica qualquer nome digitado, sem senha. Essa página responde 404 a menos que a chave esteja ativada. Não a implante.
<!-- 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>O 401 que virou 302
O primeiro teste com a chave ativada não retornou 401. Um GET /rte/gallery anônimo voltou como 302 para /SignIn.aspx?ReturnUrl=%2frte%2fgallery…. O FormsAuthenticationModule transforma todo 401 em um redirecionamento para loginUrl. O XMLHttpRequest da caixa de diálogo segue o redirecionamento sem avisar, recebe o HTML da página de login e informa um erro. A correção é uma linha no gravador de JSON; depois dela, a mesma requisição respondeu 401 {"error":"Sign in to use the image gallery."}:
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));
}Os uploads também consultam RootFor primeiro, então um upload anônimo recebe 401 em vez de "No file was uploaded."
O que testamos
Com a chave ativada, um script fez login como alice e como bob pelo SignIn.aspx, guardou o cookie .RTEDEMO de cada um e constatou:
- Os uploads de alice foram para
/uploads/users/2bd806c97f0e00af/…e os de bob para/uploads/users/81b637d8fcd2c6da/…. - alice criou
trips. A pasta estava na listagem dela, e não na de bob. - Para alice,
folder=..gerou 400 efolder=usersgerou 404. A raiz dela é a própria pasta, então ela não consegue subir atéusers/. - A URL da imagem de alice carregou sem cookie nenhum, com status 200.
O último ponto é intencional. Os arquivos em uploads/ são arquivos estáticos que o IIS serve a qualquer pessoa que tenha a URL, e uma imagem em uma página publicada precisa carregar para todos os leitores. As raízes por usuário mantêm cada usuário fora das pastas dos outros na caixa de diálogo. Elas não tornam os arquivos privados. Para arquivos privados, sirva-os por meio de um handler que verifique o usuário e mantenha-os fora da pasta do site.
A imagem acima mostra uploads depois das duas execuções. As pastas compartilhadas vêm da execução com a chave desativada, e users/ contém uma pasta por usuário autenticado.
Teste
No projeto de exemplo deste guia, defina rte:PerUserFolders como true e inicie o run.ps1. Em seguida:
curl -i "http://localhost:5401/rte/gallery?folder="
HTTP/1.1 401 Unauthorized
{"error":"Sign in to use the image gallery."}Abra /SignIn.aspx e faça login com qualquer nome. A página de demonstração mostra quem você é, e a galeria abre a sua própria pasta vazia em uploads/users/. Volte a chave para false e o teste de contrato passa de novo (35 de 35).
O mesmo guia para outras plataformas
JavaScript · PHP · ASP.NET Core · MVC 5