286 lines
14 KiB
C#
286 lines
14 KiB
C#
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<SyncEvent> 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<PlainSyncEvent> 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);
|
|
}
|
|
});
|
|
}
|
|
|
|
private static Dictionary<string, string[]> ValidateLocation(SchoolLocationRequest request)
|
|
{
|
|
var errors = new Dictionary<string, string[]>();
|
|
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);
|