Files
LehrerApp/LehrerApp.Api/Endpoints/Endpoints.cs
T
adminandClaude Sonnet 5 f0f8fa25e5 Baustein 6: Datei-Anhaenge synchronisieren (Kapitel 10)
Anhaenge laufen bewusst NICHT ueber den JSON-Ereigniskanal (wuerde ihn
fuer Fotos/Scans stark aufblaehen), sondern ueber einen eigenen
verschluesselten Binaerkanal - analog zum bereits bestehenden Muster
in SnapshotService.

- Neue Endpunkte POST/GET /api/sync/attachments/{storageId} in
  LehrerApp.Api (AttachmentStore, dateibasiert je Nutzer)
- EventQueue: neue, vom JSON-Ereignis getrennte Warteliste fuer
  ausstehende Uploads (SyncEventPublisher traegt Anhaenge einer
  gespeicherten Documentation dort ein)
- AttachmentSyncer laedt ausstehende Anhaenge hoch (in
  SyncEngine.SyncNowAsync nach dem Event-Push)
- EventApplier laedt fehlende Anhaenge nach dem Anwenden eines
  Documentation-Ereignisses nach - ueber die rohe Collection statt
  IAttachmentStorage.Upload, da dieses immer eine neue Id vergaebe und
  hier die Original-StorageId erhalten bleiben muss

Round-Trip-Tests belegen byteidentische Uebertragung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:30:04 +02:00

150 lines
7.2 KiB
C#

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.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();
if (!store.VerifyPassword(req.Username, req.Password))
return Results.Unauthorized();
return Results.Ok(new { token = Jwt(req.Username, secret), userId = req.Username });
});
}
// ── 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 });
});
}
// ── 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));
});
}
// ── 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);