using System.Security.Claims;
using Flaro.Core.Auth;
using Flaro.Core.Users;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Endpoints;
/// Jemanden über einen Link einladen, statt ein Konto für ihn zu erfinden.
///
///
///
/// Warum. Ein Konto von Hand anzulegen heisst, sich ein Passwort für
/// einen anderen auszudenken und es ihm durchzusagen — über denselben Kanal,
/// über den er den Link bekommen hätte. Wer eingeladen wird, soll sich Namen
/// und Passwort selbst geben.
///
///
/// Zwei Welten in einer Datei. Erstellen, auflisten und zurückziehen
/// ist Verwaltung. Ansehen und Einlösen muss jemand können, der noch gar kein
/// Konto hat — also anonym, und damit gedrosselt: es gibt keine globale
/// Drossel in diesem Server, ein anonymer Endpunkt ohne eigene wäre
/// ungebremst.
///
///
public static class EinladungEndpoints
{
private const int HoechstensTage = 365;
public static void MapEinladungen(this IEndpointRouteBuilder app)
{
var verwalten = app.MapGroup("/api/v1/invites")
.RequireAuthorization(FlaroPolicies.Administrator);
verwalten.MapPost("", async (
EinladungRequest anfrage, ClaimsPrincipal wer,
FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
{
if (Kennung(wer) is not { } ich) return Results.Unauthorized();
var tage = Math.Clamp(anfrage.Days, 1, HoechstensTage);
if (anfrage.MaxUses is { } anzahl && anzahl < 1)
return Einwand.Bitte("Ein Link, der nullmal gilt, ist keiner.");
// **Das Geheimnis verlässt den Server genau hier.** Gespeichert
// wird nur sein Fingerabdruck; aus der Datenbank lässt sich kein
// Link mehr bauen, und eine verlorene Einladung wird deshalb nicht
// wiedergefunden, sondern neu gemacht.
var token = Secrets.Create();
var jetzt = uhr.GetUtcNow();
var einladung = new Einladung
{
TokenFingerprint = Secrets.Fingerprint(token),
CreatedByUserId = ich,
CreatedAt = jetzt,
ExpiresAt = jetzt.AddDays(tage),
MaxUses = anfrage.MaxUses,
};
db.Einladungen.Add(einladung);
await db.SaveChangesAsync(ct);
return Results.Ok(new EinladungErstellt(
einladung.Id, token, einladung.ExpiresAt, einladung.MaxUses));
});
verwalten.MapGet("", async (FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
{
var jetzt = uhr.GetUtcNow();
var roh = await db.Einladungen.AsNoTracking()
.OrderByDescending(e => e.CreatedAt)
.Join(db.Users.AsNoTracking(), e => e.CreatedByUserId, u => u.Id,
(e, u) => new { E = e, u.UserName })
.ToListAsync(ct);
return Results.Ok(roh.Select(z => new Einladungseintrag(
z.E.Id, z.UserName, z.E.CreatedAt, z.E.ExpiresAt,
z.E.MaxUses, z.E.UsedCount, z.E.RevokedAt, z.E.Gilt(jetzt))).ToList());
});
// **Zurückziehen, nicht löschen.** Eine Zeile wegzuwerfen hiesse,
// hinterher nicht mehr zu wissen, dass es den Link je gab — und genau
// das will man wissen, wenn jemand Unerwartetes im Haushalt auftaucht.
verwalten.MapDelete("/{id:guid}", async (
Guid id, FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
{
var einladung = await db.Einladungen.FirstOrDefaultAsync(e => e.Id == id, ct);
if (einladung is null) return Einwand.Fehlt("Diese Einladung gibt es nicht mehr.");
einladung.RevokedAt ??= uhr.GetUtcNow();
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
/* ---------- Was ohne Konto erreichbar sein muss ---------- */
app.MapGet("/api/v1/invites/{token}", async (
string token, FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
{
var gefunden = await Suchen(db, token, ct);
if (Pruefen(gefunden, uhr.GetUtcNow()) is { } einwand) return einwand;
var name = await db.Users.AsNoTracking()
.Where(u => u.Id == gefunden!.CreatedByUserId)
.Select(u => u.UserName).FirstOrDefaultAsync(ct) ?? "";
var servername = await db.Settings.AsNoTracking()
.Where(s => s.Key == SystemEndpoints.NameSchlüssel)
.Select(s => s.Value).FirstOrDefaultAsync(ct);
return Results.Ok(new Einladungsschau(
string.IsNullOrWhiteSpace(servername) ? "Flaro" : servername,
name, gefunden!.ExpiresAt));
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn);
app.MapPost("/api/v1/invites/{token}/redeem", async (
string token, EinloesenRequest anfrage,
FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
{
var name = anfrage.UserName?.Trim() ?? "";
if (name.Length == 0 || (anfrage.Password?.Length ?? 0) < 8)
{
return Einwand.Bitte(
"Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen.");
}
var jetzt = uhr.GetUtcNow();
var gefunden = await Suchen(db, token, ct);
if (Pruefen(gefunden, jetzt) is { } einwand) return einwand;
// **Verbrauchen und anlegen gehören zusammen.**
//
// Zwei Leute, die gleichzeitig denselben einmaligen Link öffnen,
// läsen beide `UsedCount = 0` und schrieben beide `1` — und hätten
// beide ein Konto. Der Zähler wird deshalb mit einer Bedingung
// hochgesetzt, die die Gültigkeit noch einmal prüft; wer dabei
// keine Zeile trifft, war der Zweite.
//
// Und das Ganze in einer Transaktion: scheitert das Anlegen am
// Namen, rollt der Verbrauch mit zurück. Sonst wäre ein einmaliger
// Link an einem Tippfehler verbrannt.
await using var geschaeft = await db.Database.BeginTransactionAsync(ct);
var verbraucht = await db.Einladungen
.Where(e => e.Id == gefunden!.Id
&& e.RevokedAt == null
&& e.ExpiresAt > jetzt
&& (e.MaxUses == null || e.UsedCount < e.MaxUses))
.ExecuteUpdateAsync(s => s.SetProperty(e => e.UsedCount, e => e.UsedCount + 1), ct);
if (verbraucht != 1)
return Einwand.Bitte("Dieser Einladungslink wurde schon benutzt.");
if (await db.Users.AnyAsync(u => u.UserName == name, ct))
return Einwand.Belegt($"„{name}“ gibt es schon. Nimm einen anderen.");
db.Users.Add(new User
{
UserName = name,
PasswordHash = PasswordHasher.Hash(anfrage.Password!),
IsAdministrator = false,
});
await db.SaveChangesAsync(ct);
await geschaeft.CommitAsync(ct);
return Results.NoContent();
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn);
}
private static Task Suchen(FlaroContext db, string token, CancellationToken ct) =>
db.Einladungen.FirstOrDefaultAsync(
e => e.TokenFingerprint == Secrets.Fingerprint(token ?? ""), ct);
/// Der Grund, warum ein Link nicht gilt — oder null.
///
///
/// Unterschiedliche Sätze, mit Absicht. Alles in ein „gilt nicht"
/// zusammenzufassen wäre verschwiegener, aber hier ist Verschwiegenheit
/// am falschen Platz: wer einen Link bekommen hat und abgewiesen wird,
/// muss wissen, ob er zu spät ist oder ob jemand ihn zurückgezogen hat —
/// sonst fragt er nach, und der Gastgeber rät mit.
///
private static IResult? Pruefen(Einladung? einladung, DateTimeOffset jetzt) => einladung switch
{
null => Einwand.Fehlt("Diesen Einladungslink gibt es nicht."),
{ RevokedAt: not null } => Einwand.Bitte("Dieser Einladungslink wurde zurückgezogen."),
var e when jetzt >= e.ExpiresAt => Einwand.Bitte("Dieser Einladungslink ist abgelaufen."),
var e when e.MaxUses is { } h && e.UsedCount >= h =>
Einwand.Bitte("Dieser Einladungslink wurde schon benutzt."),
_ => null,
};
private static Guid? Kennung(ClaimsPrincipal wer) =>
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
}