Pastas de imagens por usuário no ASP.NET Core

A pasta uploads do exemplo em ASP.NET Core no disco, com pastas compartilhadas e uma pasta users contendo alice e bob, cada um com as próprias fotos

Por padrão, a galeria mostra as mesmas pastas para todo mundo, o que serve para um site de empresa. Em um fórum ou em um aplicativo com vários clientes, cada pessoa só deve ver as próprias imagens. Nos exemplos ASP.NET Core, isso é um único método, RootFor(HttpContext), que decide onde ficam os arquivos de uma requisição. Esta página mostra a versão por usuário, o login de que ela precisa e o que continua público.

Um método decide

Toda rota (listar, criar pasta, upload) pede a RootFor uma pasta no disco e a URL em que ela é servida. Nenhuma outra parte do código sabe nada sobre usuários.

C# · RteFiles.cs
// Where this request's files live. This is the only method to change for per-user folders.
//   "PerUserFolders": false (default)  everyone shares <content root>/uploads, served at /uploads
//   "PerUserFolders": true             each signed-in user gets uploads/users/<user id>
public static RteRoot RootFor(HttpContext http)
{
    var uploads = UploadsFolder(http.RequestServices.GetRequiredService<IWebHostEnvironment>());
    if (!http.RequestServices.GetRequiredService<IConfiguration>().GetValue<bool>("PerUserFolders"))
        return new RteRoot(uploads, "/uploads");

    // Use the user's id (a GUID with ASP.NET Core Identity), not the user name, which can change.
    var id = http.User.FindFirstValue(ClaimTypes.NameIdentifier)
             ?? throw new RteError(401, "Sign in to use the image gallery.");
    // The id becomes a folder name, so it must pass the same rules as any other folder name.
    if (!IsValidName(id)) throw new RteError(403, "This user id cannot be used as a folder name.");
    return new RteRoot(Path.Combine(uploads, "users", id), "/uploads/users/" + Uri.EscapeDataString(id));
}
  • PerUserFolders em appsettings.json é false por padrão. Com true, cada usuário autenticado recebe uploads/users/<id>/ como raiz da galeria. O botão New folder e os uploads funcionam dentro dela, e .. não consegue sair dela, porque a verificação de pasta é feita em relação a essa raiz.
  • O id é ClaimTypes.NameIdentifier. Com o ASP.NET Core Identity, essa claim contém o id do usuário, um GUID, que nunca muda. Um nome de usuário ou endereço de e-mail pode mudar, e no Windows Alice e alice seriam a mesma pasta.
  • Sem usuário, sem galeria. Uma requisição anônima recebe 401 {"error":"Sign in to use the image gallery."}, e a caixa de diálogo mostra sua própria mensagem, "The image gallery is unavailable right now (http-401).", em vez do texto do servidor.
  • O id é verificado como um nome de pasta. Um id como ../../x recebe um 403 em vez de um caminho.

Login

O exemplo usa autenticação por cookie, o mesmo esquema com que o ASP.NET Core Identity faz login. O login do seu próprio aplicativo substitui o login de teste abaixo.

C# · Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
// Your app's sign-in goes here. "PerUserFolders": true in appsettings.json gives each user a folder.
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie();
var app = builder.Build();

app.UseStaticFiles();
app.UseRteStaticFiles();          // /uploads, /richtexteditor, /rte-files.js
app.UseAuthentication();
app.UseAuthorization();

app.MapDefaultControllerRoute();  // HomeController.Index at "/", plus RteController's [Route] attributes

Para testar as pastas por usuário sem criar um login, o exemplo mapeia um login só para testes, e apenas no ambiente Development. Ele autentica qualquer pessoa como qualquer usuário, então nunca pode chegar a um servidor real:

C# · Program.cs
// TEST ONLY, and mapped only in Development: /demo/sign-in?user=alice signs anyone in as anyone,
// so per-user folders can be tried without a real login. Never ship this.
if (app.Environment.IsDevelopment())
{
    app.MapGet("/demo/sign-in", async (HttpContext http, string user) =>
    {
        var identity = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, user)], CookieAuthenticationDefaults.AuthenticationScheme);
        await http.SignInAsync(new ClaimsPrincipal(identity));
        return Results.Redirect("/");
    });
}

Com um simples dotnet run, que roda como Production, /demo/sign-in respondeu 404.

O que continua público

Galerias separadas não tornam os arquivos privados. /uploads/users/alice/harbor-fd5ff24b.jpg é servido pelo mesmo mapeamento de arquivos estáticos que qualquer outro upload, e respondeu 200 a uma requisição sem cookie. Isso costuma ser o certo para imagens em conteúdo publicado: quem lê um post precisa conseguir carregar as imagens dele. A parte aleatória do nome do arquivo impede que alguém adivinhe a URL, mas qualquer pessoa com quem ela for compartilhada consegue abri-la. Se as imagens de um usuário precisam ficar privadas, sirva-as por um endpoint que verifique o usuário, e não por UseStaticFiles. O exemplo não inclui um.

Ativar as pastas por usuário também não decide quem pode fazer upload. Adicione [Authorize] ao controller, ou .RequireAuthorization() ao grupo da Minimal API, quando o aplicativo tiver login.

Teste

Inicie o projeto de exemplo deste guia em Development com as pastas por usuário ativadas e trabalhe como dois usuários, com dois arquivos de cookies:

Shell
dotnet run -- --environment Development --PerUserFolders true

curl "http://localhost:5302/rte/gallery?folder="
{"error":"Sign in to use the image gallery."}

curl -s -o /dev/null -c alice.jar "http://localhost:5302/demo/sign-in?user=alice"
curl -b alice.jar -F "file=@harbor.jpg" http://localhost:5302/rte/upload
{"url":"/uploads/users/alice/harbor-fd5ff24b.jpg"}

curl -s -o /dev/null -c bob.jar "http://localhost:5302/demo/sign-in?user=bob"
curl -b bob.jar -F "file=@forest.jpg" http://localhost:5302/rte/upload
{"url":"/uploads/users/bob/forest-8b1a0f16.jpg"}

curl -b bob.jar "http://localhost:5302/rte/gallery?folder="
{"currentFolder":"","currentFolderDisplay":"/","parentFolder":null,"folders":[],"images":[{"url":"/uploads/users/bob/forest-8b1a0f16.jpg","name":"forest-8b1a0f16.jpg","folder":"","size":36337}]}

A galeria de Bob lista só a imagem dele. No navegador, abra http://localhost:5302/demo/sign-in?user=alice e clique em Insert Gallery: ela mostra apenas as imagens e pastas de Alice. A imagem no topo desta página é a pasta uploads depois disso, com users/alice e users/bob ao lado das pastas compartilhadas que o exemplo usa quando PerUserFolders está desativado.

O mesmo guia para outras plataformas

JavaScript · PHP · MVC 5 · Web Forms

Leia a seguir