Das eigene Bild führte aus dem Server heraus
Ein Klick auf den Buchstabenkreis meldete ab. Keine Rückfrage, kein Menü, kein Weg zurück — hinter einer Geste, die überall sonst „mein Konto" heisst. Jetzt führt er auf `#/profil`; Abmelden steht dort als Knopf, mit Nachfrage. Auf der Seite: Bild wählen, tauschen, entfernen. Dazu „Meine Sprache" — **umgezogen, nicht kopiert.** Sie stand mitten in der Verwaltung, und die zeigt der Client nur Administratoren; der Endpunkt erlaubte sie längst jedem. Wer kein Admin war, konnte seine eigene Sprache nirgends ändern. Der Warnhinweis „weicht ab" bleibt in der Verwaltung, denn dort entsteht die Verwirrung — er verweist jetzt aufs Profil. **Vorher musste ein Fehler weg, der seit Monaten dasteht.** `ImageCache.StoreStreamAsync` baute den Schlüssel aus 32 Hexzeichen plus Dateiendung; `PfadFür` verlangt genau 64 Hexzeichen und nichts sonst. Es gab keinen Wert, den beide gelten liessen: **jeder Bildupload gab `null` zurück und endete in einem 502.** Kein Test fragte je danach — jetzt vier. Damit geht nebenbei auch „eigenes Bild hochladen" für Filme und Serien wieder. Weil der Schlüssel keine Endung mehr trägt, kommt der Medientyp beim Ausliefern aus den ersten Bytes der Datei. Vorher ging alles als `image/jpeg` hinaus — bei TMDB stimmte das, bei einem hochgeladenen PNG nicht. **Und die gemerkte Sitzung war eine Kopie, die nie nachzog.** Sie wurde bei der Anmeldung geschrieben und danach nie wieder. Wer sein Bild am Telefon setzt und den Browser am Schreibtisch öffnet, fand dort den alten Buchstaben — bis zum nächsten Anmelden. Gemessen und behoben: beim Hochfahren wird das Konto abgeglichen, und das Profil meldet Änderungen an die Kopfleiste, damit sie ohne Neuladen nachzieht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9622da0284
commit
d8a1633cf4
@@ -0,0 +1,100 @@
|
||||
using System.Security.Claims;
|
||||
using Flaro.Core.Metadata;
|
||||
using Flaro.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api.Endpoints;
|
||||
|
||||
/// <summary>Das eigene Konto — für jeden, nicht nur für Administratoren.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Eine eigene Gruppe, weil die Rechte andere sind.</b> Alles unter
|
||||
/// <c>/api/v1/users</c> war bisher Verwaltung und damit Administratoren
|
||||
/// vorbehalten. Hier geht es um das eigene Konto: wer angemeldet ist, darf
|
||||
/// sein Bild tauschen, und niemand darf das eines anderen anfassen. Die
|
||||
/// Kennung kommt deshalb aus dem Token und nie aus der Adresse.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Es war bisher nicht erreichbar.</b> „Meine Sprache" stand mitten in der
|
||||
/// Verwaltung, und die zeigt der Client nur Administratoren — der Endpunkt
|
||||
/// erlaubte es längst jedem. Wer kein Admin war, konnte seine Sprache also
|
||||
/// nirgends ändern.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ProfilEndpoints
|
||||
{
|
||||
/// <summary>Höchstens so gross darf ein Profilbild sein.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Fünf Megabyte sind für ein Bild von 200 Pixeln reichlich. Dieselbe
|
||||
/// Grenze wie bei Plakaten wäre hier zu grosszügig: ein Plakat sieht man
|
||||
/// gross, einen Kreis von 28 Pixeln nicht.
|
||||
/// </remarks>
|
||||
private const long HoechstensBytes = 5 * 1024 * 1024;
|
||||
|
||||
public static void MapProfil(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var gruppe = app.MapGroup("/api/v1/users/me").RequireAuthorization();
|
||||
|
||||
gruppe.MapGet("", async (ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
|
||||
{
|
||||
if (Kennung(wer) is not { } id) return Results.Unauthorized();
|
||||
|
||||
var benutzer = await db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id, ct);
|
||||
return benutzer is null
|
||||
? Results.Unauthorized()
|
||||
: Results.Ok(new AuthenticatedUser(
|
||||
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator,
|
||||
benutzer.Language, benutzer.AvatarImage));
|
||||
});
|
||||
|
||||
gruppe.MapPost("/avatar", async (
|
||||
HttpRequest anfrage, ClaimsPrincipal wer, FlaroContext db,
|
||||
IImageStore ablage, CancellationToken ct) =>
|
||||
{
|
||||
if (Kennung(wer) is not { } id) return Results.Unauthorized();
|
||||
if (!anfrage.HasFormContentType)
|
||||
return Results.BadRequest(new { fehler = "Kein Formular." });
|
||||
|
||||
var form = await anfrage.ReadFormAsync(ct);
|
||||
var datei = form.Files.FirstOrDefault();
|
||||
if (datei is null || datei.Length == 0)
|
||||
return Results.BadRequest(new { fehler = "Keine Datei dabei." });
|
||||
if (datei.Length > HoechstensBytes)
|
||||
return Results.BadRequest(new { fehler = "Höchstens 5 MB." });
|
||||
|
||||
await using var strom = datei.OpenReadStream();
|
||||
var schluessel = await ablage.StoreStreamAsync(strom, datei.ContentType, ct);
|
||||
if (schluessel is null)
|
||||
return Results.Problem("Das Bild liess sich nicht ablegen.", statusCode: 502);
|
||||
|
||||
var benutzer = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
|
||||
if (benutzer is null) return Results.Unauthorized();
|
||||
|
||||
benutzer.AvatarImage = schluessel;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new { image = schluessel });
|
||||
}).DisableAntiforgery();
|
||||
|
||||
// **Entfernen heisst vergessen, nicht löschen.** Die Datei bleibt in
|
||||
// der Bildablage liegen — dort liegt sie unter dem Hash ihres Inhalts
|
||||
// und könnte an einem anderen Konto hängen. Was dort aufräumt, ist
|
||||
// „Bildspeicher leeren" in der Verwaltung, und das kennt alle Bezüge.
|
||||
gruppe.MapDelete("/avatar", async (ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
|
||||
{
|
||||
if (Kennung(wer) is not { } id) return Results.Unauthorized();
|
||||
|
||||
var benutzer = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
|
||||
if (benutzer is null) return Results.Unauthorized();
|
||||
|
||||
benutzer.AvatarImage = null;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.NoContent();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Wer anfragt — aus dem Token, nie aus der Adresse.</summary>
|
||||
private static Guid? Kennung(ClaimsPrincipal wer) =>
|
||||
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
|
||||
}
|
||||
Reference in New Issue
Block a user