using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using LehrerApp.Core.Models; using LehrerApp.Sync.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.IdentityModel.Tokens; namespace LehrerApp.Api; public static class Endpoints { // ── Auth ────────────────────────────────────────────────────────────────── public static void MapAuthEndpoints(this WebApplication app, string secret) { app.MapPost("/api/auth/login", (LoginRequest req, UserStore store) => { if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password)) return Results.Unauthorized(); var user = store.Authenticate(req.Username, req.Password); // Der kanonisch gespeicherte Username aus UserStore.Authenticate (nicht req.Username!) // wird als JWT-userId verwendet - LiteDBs case-insensitive Standard-Collation lässt // einen Login mit abweichender Groß-/Kleinschreibung erfolgreich durch; würde man // stattdessen req.Username übernehmen, würde jede andersartig getippte Anmeldung einen // eigenen, komplett getrennten Server-seitigen Event-Speicher erzeugen (siehe // UserStore.Authenticate). if (user is null) return Results.Unauthorized(); return Results.Ok(new { token = Jwt(user.Username, secret), userId = user.Username }); }).RequireRateLimiting("login"); } // ── Sync ────────────────────────────────────────────────────────────────── public static void MapSyncEndpoints(this WebApplication app) { var g = app.MapGroup("/api/sync").RequireAuthorization(); g.MapPost("/push", ([FromBody] List events, ClaimsPrincipal user, EventStore store) => { var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; return uid is null ? Results.Unauthorized() : Results.Ok(store.Push(uid, events)); }); g.MapGet("/pull", ([FromQuery] long since, [FromQuery] string deviceId, ClaimsPrincipal user, EventStore store) => { var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; return uid is null ? Results.Unauthorized() : Results.Ok(store.Pull(uid, since, deviceId)); }); g.MapGet("/status", (ClaimsPrincipal user) => { var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; return uid is null ? Results.Unauthorized() : Results.Ok(new { userId = uid, timestamp = DateTime.UtcNow }); }); // Für Clients, deren Push wegen eines neueren Server-Stands abgelehnt wurde (10.3.4- // Nachtrag: exakte Kollisionsprüfung statt 30s-Heuristik) — sofortiges Nachladen des // aktuellen Stands EINER Entität, statt auf den nächsten regulären Pull zu warten. g.MapGet("/entity/{entityType}/{entityId}", (string entityType, string entityId, ClaimsPrincipal user, EventStore store) => { var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (uid is null) return Results.Unauthorized(); var result = store.GetLatestForEntity(uid, entityType, entityId); return result is null ? Results.NotFound() : Results.Ok(result); }); } // ── Anhänge (eigener Binärkanal, getrennt vom JSON-Ereigniskanal) ────────── public static void MapAttachmentEndpoints(this WebApplication app) { var g = app.MapGroup("/api/sync/attachments").RequireAuthorization(); g.MapPost("/{storageId}", async (string storageId, HttpRequest req, ClaimsPrincipal user, AttachmentStore store) => { var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (uid is null) return Results.Unauthorized(); if (req.ContentLength is null or > LehrerApp.Core.Interfaces.IAttachmentStorage.MaxSizeBytes) return Results.BadRequest("Datei zu groß oder Content-Length fehlt."); await store.StoreAsync(uid, storageId, req.Body); return Results.Ok(); }); g.MapGet("/{storageId}", (string storageId, ClaimsPrincipal user, AttachmentStore store) => { var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (uid is null) return Results.Unauthorized(); var stream = store.OpenRead(uid, storageId); return stream is null ? Results.NotFound() : Results.Stream(stream, "application/octet-stream"); }); } // ── Snapshot (Device-Pairing) ───────────────────────────────────────────── public static void MapSnapshotEndpoints(this WebApplication app) { var g = app.MapGroup("/api/snapshot").RequireAuthorization(); g.MapPost("/upload", ([FromBody] SnapshotUploadRequest req, ClaimsPrincipal user, SnapshotStore store) => { var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; return uid is null ? Results.Unauthorized() : Results.Ok(store.Store(uid, req)); }); g.MapGet("/{code}", (string code, ClaimsPrincipal user, SnapshotStore store) => { var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (uid is null) return Results.Unauthorized(); var result = store.Retrieve(uid, code); return result is null ? Results.NotFound("Snapshot nicht gefunden, abgelaufen oder bereits verwendet.") : Results.Ok(result); }); } // ── Readable Snapshot (WebApp) ──────────────────────────────────────────── public static void MapReadableSnapshotEndpoints(this WebApplication app) { var g = app.MapGroup("/api/snapshot/readable").RequireAuthorization(); g.MapPost("/", ([FromBody] ReadableSnapshot snap, ClaimsPrincipal user, ReadableSnapshotStore store) => { var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (uid is null) return Results.Unauthorized(); snap.ExportedAt = DateTime.UtcNow; store.Store(uid, snap); return Results.Ok(new { exportedAt = snap.ExportedAt, studentCount = snap.Meta.StudentCount }); }); g.MapGet("/", (ClaimsPrincipal user, ReadableSnapshotStore store) => { var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (uid is null) return Results.Unauthorized(); var snap = store.Load(uid); return snap is null ? Results.NotFound("Kein Snapshot vorhanden.") : Results.Ok(snap); }); } // ── Plain Sync (WebApp schreibt Events) ─────────────────────────────────── public static void MapPlainSyncEndpoints(this WebApplication app) { var g = app.MapGroup("/api/sync/plain").RequireAuthorization(); g.MapPost("/push", ([FromBody] List events, ClaimsPrincipal user, PlainEventStore store) => { var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; return uid is null ? Results.Unauthorized() : Results.Ok(store.Push(uid, events)); }); } // ── JWT ─────────────────────────────────────────────────────────────────── private static string Jwt(string userId, string secret) { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( claims: [new(ClaimTypes.NameIdentifier, userId), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())], expires: DateTime.UtcNow.AddDays(30), signingCredentials: creds); return new JwtSecurityTokenHandler().WriteToken(token); } } public record LoginRequest(string Username, string Password);