using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using System.Globalization; 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)); }); } // ── Schulstandort und DWD-Wetter ───────────────────────────────────────── public static void MapSchoolWeatherEndpoints(this WebApplication app) { var g = app.MapGroup("/api/school").RequireAuthorization(); g.MapGet("/location", (ClaimsPrincipal user, SchoolWeatherStore store) => { var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (uid is null) return Results.Unauthorized(); var location = store.GetLocation(uid); return location is null ? Results.NotFound("Noch kein Schulstandort hinterlegt.") : Results.Ok(location); }); g.MapPut("/location", async ([FromBody] SchoolLocationRequest request, ClaimsPrincipal user, SchoolWeatherStore store, IGeocoder geocoder, CancellationToken cancellationToken) => { var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (uid is null) return Results.Unauthorized(); var errors = ValidateLocation(request); if (errors.Count > 0) return Results.ValidationProblem(errors); var existing = store.GetLocation(uid); if (existing is not null && SameLocation(existing, request)) return Results.Ok(existing); GeocodingResult? result; try { result = await geocoder.GeocodeAsync(request, cancellationToken); } catch (HttpRequestException) { return Results.Problem("Der Geocoding-Dienst ist momentan nicht erreichbar.", statusCode: StatusCodes.Status503ServiceUnavailable); } catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) { return Results.Problem("Die Adressprüfung hat zu lange gedauert.", statusCode: StatusCodes.Status503ServiceUnavailable); } if (result is null) return Results.UnprocessableEntity(new { detail = "Die Schuladresse konnte nicht eindeutig gefunden werden. Bitte Schreibweise, PLZ und Ort prüfen.", }); var profile = new SchoolLocationProfile { SchoolName = request.SchoolName.Trim(), Street = request.Street.Trim(), PostalCode = request.PostalCode.Trim(), City = request.City.Trim(), State = request.State, Latitude = result.Latitude, Longitude = result.Longitude, ResolvedAddress = result.DisplayName, UpdatedAt = DateTime.UtcNow, }; store.SaveLocation(uid, profile); return Results.Ok(profile); }); g.MapGet("/weather", async (ClaimsPrincipal user, SchoolWeatherStore store, DwdWeatherService weather, CancellationToken cancellationToken) => { var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (uid is null) return Results.Unauthorized(); var location = store.GetLocation(uid); if (location is null) return Results.NotFound("Noch kein Schulstandort hinterlegt."); var cached = store.GetWeather(uid); if (cached is not null && DateTime.UtcNow - cached.RetrievedAt.ToUniversalTime() < TimeSpan.FromMinutes(5)) return Results.Ok(cached); try { var current = await weather.GetAsync(location, cancellationToken); store.SaveWeather(uid, current); return Results.Ok(current); } catch (Exception ex) when (ex is HttpRequestException or InvalidDataException or InvalidOperationException) { if (cached is not null) { cached.IsStale = true; return Results.Ok(cached); } return Results.Problem("Der DWD ist momentan nicht erreichbar und es liegt noch kein Wetter-Cache vor.", statusCode: StatusCodes.Status503ServiceUnavailable); } }); } // ── WebUntis (granulare Abrufe über benutzergebundene RAM-Sitzungen) ────── public static void MapWebUntisEndpoints(this WebApplication app) { var group = app.MapGroup("/api/webuntis").RequireAuthorization(); group.MapGet("/connection", (ClaimsPrincipal user, WebUntisConnectionStore connections) => { var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; return uid is null ? Results.Unauthorized() : Results.Ok(connections.GetStatus(uid)); }); group.MapPost("/connection", async (WebUntisConnectRequest request, ClaimsPrincipal user, WebUntisConnectionStore connections, CancellationToken cancellationToken) => { var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (uid is null) return Results.Unauthorized(); if (string.IsNullOrWhiteSpace(request.School) || string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) return Results.BadRequest("Schule, Benutzername und Passwort sind erforderlich."); return await WebUntisResult(() => connections.ConnectAsync(uid, request, cancellationToken)); }); group.MapDelete("/connection", async (ClaimsPrincipal user, WebUntisConnectionStore connections) => { var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (uid is null) return Results.Unauthorized(); await connections.DisconnectAsync(uid); return Results.NoContent(); }); group.MapGet("/schoolyears", (ClaimsPrincipal user, WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) => WithWebUntisClient(user, connections, fallback, client => client.GetSchoolYearsAsync(cancellationToken))); group.MapGet("/classes", ([FromQuery] int schoolyearId, ClaimsPrincipal user, WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) => schoolyearId <= 0 ? Task.FromResult(Results.BadRequest("schoolyearId muss größer als 0 sein.")) : WithWebUntisClient(user, connections, fallback, client => client.GetClassesAsync(schoolyearId, cancellationToken))); group.MapGet("/teachers", (ClaimsPrincipal user, WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) => WithWebUntisClient(user, connections, fallback, client => client.GetTeachersAsync(cancellationToken))); group.MapGet("/student-report", ([FromQuery] string? className, ClaimsPrincipal user, WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) => WithWebUntisClient(user, connections, fallback, client => client.GetStudentReportAsync(className, cancellationToken))); group.MapGet("/holidays", (ClaimsPrincipal user, WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) => WithWebUntisClient(user, connections, fallback, client => client.GetHolidaysAsync(cancellationToken))); group.MapGet("/timegrid", (ClaimsPrincipal user, WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) => WithWebUntisClient(user, connections, fallback, client => client.GetTimeGridAsync(cancellationToken))); group.MapGet("/substitutions", ([FromQuery] int startDate, [FromQuery] int endDate, [FromQuery] int? departmentId, ClaimsPrincipal user, WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) => { if (!ValidDateRange(startDate, endDate, 31, out var error)) return Task.FromResult(Results.BadRequest(error)); return WithWebUntisClient(user, connections, fallback, client => client.GetSubstitutionsAsync(startDate, endDate, departmentId, cancellationToken)); }); group.MapGet("/timetable", ([FromQuery] string elementType, [FromQuery] int elementId, [FromQuery] int startDate, [FromQuery] int endDate, ClaimsPrincipal user, WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) => { if (!Enum.TryParse(elementType, true, out var parsedType) || !Enum.IsDefined(parsedType)) return Task.FromResult(Results.BadRequest( "elementType muss class, teacher, subject, room oder student sein.")); if (elementId <= 0) return Task.FromResult(Results.BadRequest("elementId muss größer als 0 sein.")); if (!ValidDateRange(startDate, endDate, 62, out var error)) return Task.FromResult(Results.BadRequest(error)); return WithWebUntisClient(user, connections, fallback, client => client.GetTimetableAsync(parsedType, elementId, startDate, endDate, cancellationToken)); }); group.MapGet("/students/{studentKey:int}/absences", (int studentKey, [FromQuery] int startDate, [FromQuery] int endDate, ClaimsPrincipal user, WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) => { if (studentKey <= 0) return Task.FromResult(Results.BadRequest("studentKey muss größer als 0 sein.")); if (!ValidDateRange(startDate, endDate, 400, out var error)) return Task.FromResult(Results.BadRequest(error)); return WithWebUntisClient(user, connections, fallback, client => client.GetStudentAbsencesAsync(studentKey, startDate, endDate, cancellationToken)); }); group.MapGet("/students/{studentId:int}/class-register-entries", (int studentId, [FromQuery] int startDate, [FromQuery] int endDate, ClaimsPrincipal user, WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) => { if (studentId <= 0) return Task.FromResult(Results.BadRequest("studentId muss größer als 0 sein.")); if (!ValidDateRange(startDate, endDate, 400, out var error)) return Task.FromResult(Results.BadRequest(error)); return WithWebUntisClient(user, connections, fallback, client => client.GetClassRegisterEntriesAsync(studentId, startDate, endDate, cancellationToken)); }); group.MapGet("/class-register/categories", (ClaimsPrincipal user, WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) => WithWebUntisClient(user, connections, fallback, client => client.GetClassRegisterCategoriesAsync(cancellationToken))); group.MapGet("/class-register/category-groups", (ClaimsPrincipal user, WebUntisConnectionStore connections, WebUntisClient fallback, CancellationToken cancellationToken) => WithWebUntisClient(user, connections, fallback, client => client.GetClassRegisterCategoryGroupsAsync(cancellationToken))); } private static Task WithWebUntisClient(ClaimsPrincipal user, WebUntisConnectionStore connections, WebUntisClient fallback, Func> operation) { var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (uid is null) return Task.FromResult(Results.Unauthorized()); return WebUntisResult(() => operation(connections.GetClient(uid) ?? fallback)); } private static async Task WebUntisResult(Func> operation) { try { return Results.Ok(await operation()); } catch (WebUntisConfigurationException exception) { return Results.Problem(exception.Message, statusCode: StatusCodes.Status503ServiceUnavailable); } catch (WebUntisException exception) { return Results.Problem(exception.Message, statusCode: StatusCodes.Status502BadGateway); } catch (InvalidDataException exception) { return Results.Problem($"Der WebUntis-Report ist ungültig: {exception.Message}", statusCode: StatusCodes.Status502BadGateway); } } private static bool ValidDateRange(int startDate, int endDate, int maximumDays, out string error) { error = ""; if (!DateOnly.TryParseExact(startDate.ToString(CultureInfo.InvariantCulture), "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var start) || !DateOnly.TryParseExact(endDate.ToString(CultureInfo.InvariantCulture), "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var end)) { error = "startDate und endDate müssen gültige Datumswerte im Format yyyyMMdd sein."; return false; } if (end < start) { error = "endDate darf nicht vor startDate liegen."; return false; } if (end.DayNumber - start.DayNumber + 1 > maximumDays) { error = $"Der Zeitraum darf höchstens {maximumDays} Tage umfassen."; return false; } return true; } private static Dictionary ValidateLocation(SchoolLocationRequest request) { var errors = new Dictionary(); if (string.IsNullOrWhiteSpace(request.SchoolName)) errors[nameof(request.SchoolName)] = ["Bitte den Namen der Schule angeben."]; if (string.IsNullOrWhiteSpace(request.Street)) errors[nameof(request.Street)] = ["Bitte Straße und Hausnummer angeben."]; if (!System.Text.RegularExpressions.Regex.IsMatch(request.PostalCode?.Trim() ?? "", @"^\d{5}$")) errors[nameof(request.PostalCode)] = ["Bitte eine fünfstellige deutsche PLZ angeben."]; if (string.IsNullOrWhiteSpace(request.City)) errors[nameof(request.City)] = ["Bitte den Ort angeben."]; if (!Enum.IsDefined(request.State)) errors[nameof(request.State)] = ["Das Bundesland ist ungültig."]; return errors; } private static bool SameLocation(SchoolLocationProfile existing, SchoolLocationRequest request) => string.Equals(existing.SchoolName, request.SchoolName.Trim(), StringComparison.OrdinalIgnoreCase) && string.Equals(existing.Street, request.Street.Trim(), StringComparison.OrdinalIgnoreCase) && string.Equals(existing.PostalCode, request.PostalCode.Trim(), StringComparison.Ordinal) && string.Equals(existing.City, request.City.Trim(), StringComparison.OrdinalIgnoreCase) && existing.State == request.State; // ── 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);