This commit is contained in:
2026-03-29 23:47:31 +02:00
commit 216d5d2280
75 changed files with 5702 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
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 jwtSecret)
{
app.MapPost("/api/auth/login", (LoginRequest req) =>
{
// TODO: Passwort-Hash gegen DB prüfen
if (string.IsNullOrWhiteSpace(req.Username) ||
string.IsNullOrWhiteSpace(req.Password))
return Results.Unauthorized();
var token = GenerateJwt(req.Username, jwtSecret);
return Results.Ok(new { token, userId = req.Username });
});
app.MapPost("/api/auth/register", (RegisterRequest req) =>
{
// TODO: User anlegen, Passwort hashen (BCrypt)
if (string.IsNullOrWhiteSpace(req.Username) ||
req.Password.Length < 12)
return Results.BadRequest(
"Passwort muss mindestens 12 Zeichen haben.");
var token = GenerateJwt(req.Username, jwtSecret);
return Results.Ok(new { token, userId = req.Username });
});
}
// ── Sync ──────────────────────────────────────────────────────────────────
public static void MapSyncEndpoints(this WebApplication app)
{
var sync = app.MapGroup("/api/sync").RequireAuthorization();
sync.MapPost("/push", (
[FromBody] List<SyncEvent> events,
ClaimsPrincipal user,
EventStore store) =>
{
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userId is null) return Results.Unauthorized();
var result = store.Push(userId, events);
return Results.Ok(result);
});
sync.MapGet("/pull", (
[FromQuery] long since,
[FromQuery] string deviceId,
ClaimsPrincipal user,
EventStore store) =>
{
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userId is null) return Results.Unauthorized();
var result = store.Pull(userId, since, deviceId);
return Results.Ok(result);
});
sync.MapGet("/status", (ClaimsPrincipal user) =>
{
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userId is null) return Results.Unauthorized();
return Results.Ok(new { userId, timestamp = DateTime.UtcNow });
});
}
// ── Snapshot ──────────────────────────────────────────────────────────────
public static void MapSnapshotEndpoints(this WebApplication app)
{
var snap = app.MapGroup("/api/snapshot").RequireAuthorization();
// Sender: Snapshot hochladen → Einmal-Code erhalten
snap.MapPost("/upload", (
[FromBody] SnapshotUploadRequest request,
ClaimsPrincipal user,
SnapshotStore store) =>
{
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userId is null) return Results.Unauthorized();
if (string.IsNullOrWhiteSpace(request.EncryptedPayload))
return Results.BadRequest("Kein Payload.");
var result = store.Store(userId, request);
return Results.Ok(result);
});
// Empfänger: Snapshot per Code abrufen (Einmal-Verwendung)
snap.MapGet("/{code}", (
string code,
ClaimsPrincipal user,
SnapshotStore store) =>
{
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userId is null) return Results.Unauthorized();
var result = store.Retrieve(userId, code);
if (result is null)
return Results.NotFound(
"Snapshot nicht gefunden, abgelaufen oder bereits verwendet.");
return Results.Ok(result);
});
}
// ── JWT ───────────────────────────────────────────────────────────────────
private static string GenerateJwt(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 Claim(ClaimTypes.NameIdentifier, userId),
new Claim(JwtRegisteredClaimNames.Jti,
Guid.NewGuid().ToString()),
],
expires: DateTime.UtcNow.AddDays(30),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
public record LoginRequest(string Username, string Password);
public record RegisterRequest(string Username, string Password, string DisplayName);
// Ergänzung wird unten in der bestehenden Datei angefügt
// In Program.cs: app.MapReadableSnapshotEndpoints(); und app.MapPlainSyncEndpoints();

View File

@@ -0,0 +1,115 @@
using System.Security.Claims;
using LehrerApp.Core.Models;
using LehrerApp.Sync.Models;
using Microsoft.AspNetCore.Mvc;
namespace LehrerApp.Api;
public static class ReadableSnapshotEndpoints
{
// ── Readable Snapshot ─────────────────────────────────────────────────────
public static void MapReadableSnapshotEndpoints(this WebApplication app)
{
var snap = app.MapGroup("/api/snapshot/readable")
.RequireAuthorization();
// Desktop → Server: aktuellen Snapshot hochladen
snap.MapPost("/", (
[FromBody] ReadableSnapshot snapshot,
ClaimsPrincipal user,
ReadableSnapshotStore store) =>
{
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userId is null) return Results.Unauthorized();
snapshot.ExportedAt = DateTime.UtcNow;
store.Store(userId, snapshot);
return Results.Ok(new
{
exportedAt = snapshot.ExportedAt,
studentCount = snapshot.Meta.StudentCount,
groupCount = snapshot.Meta.GroupCount,
});
});
// WebApp → Server: Snapshot laden
snap.MapGet("/", (
ClaimsPrincipal user,
ReadableSnapshotStore store) =>
{
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userId is null) return Results.Unauthorized();
var snapshot = store.Load(userId);
if (snapshot is null)
return Results.NotFound(
"Kein Snapshot vorhanden. " +
"Bitte zuerst den Desktop-Client mit dem Server verbinden.");
return Results.Ok(snapshot);
});
// Nur Metadaten für Freshness-Check der WebApp
snap.MapGet("/meta", (
ClaimsPrincipal user,
ReadableSnapshotStore store) =>
{
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userId is null) return Results.Unauthorized();
var meta = store.LoadMeta(userId);
if (meta is null) return Results.NotFound();
return Results.Ok(meta);
});
}
// ── Plain Sync (WebApp → EventStore) ─────────────────────────────────────
public static void MapPlainSyncEndpoints(this WebApplication app)
{
var sync = app.MapGroup("/api/sync/plain")
.RequireAuthorization();
// WebApp schreibt Events (Klartext) werden beim Desktop-Pull abgeholt
sync.MapPost("/push", (
[FromBody] List<PlainSyncEvent> events,
ClaimsPrincipal user,
PlainEventStore store) =>
{
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userId is null) return Results.Unauthorized();
// Nur erlaubte EntityTypes für WebApp-Schreibzugriff
var allowed = new HashSet<string>
{
"Grade", "ExamResult", "WorkTask", "Lesson"
};
var rejected = events
.Where(e => !allowed.Contains(e.EntityType))
.Select(e => e.EventId)
.ToList();
var permitted = events
.Where(e => allowed.Contains(e.EntityType))
.ToList();
PlainPushResponse result;
if (permitted.Count > 0)
result = store.Push(userId, permitted);
else
result = new PlainPushResponse { Success = true };
// Abgelehnte Events mit Grund zurückmelden
result = result with
{
RejectedEventIds = [.. result.RejectedEventIds, .. rejected],
};
return Results.Ok(result);
});
}
}

144
LehrerApp.Api/EventStore.cs Normal file
View File

@@ -0,0 +1,144 @@
using LiteDB;
using LehrerApp.Sync.Models;
namespace LehrerApp.Api;
/// <summary>
/// Server-seitiger Event-Speicher.
/// Speichert verschlüsselte Events pro User versteht den Payload nicht.
/// Eine LiteDB-Datei pro User in dataPath/{userId}.db
/// </summary>
public class EventStore
{
private readonly string _dataPath;
private readonly Dictionary<string, LiteDatabase> _dbs = new();
private readonly Lock _lock = new();
public EventStore(string dataPath)
{
_dataPath = dataPath;
Directory.CreateDirectory(dataPath);
}
// ── Push: Events vom Client entgegennehmen ────────────────────────────────
public PushResponse Push(string userId, List<SyncEvent> events)
{
var db = GetDb(userId);
var col = db.GetCollection<ServerEvent>("events");
col.EnsureIndex(x => x.ServerSequenceNr);
var conflictIds = new List<Guid>();
long serverSeq = GetLastSequenceNr(col);
foreach (var evt in events.OrderBy(e => e.Timestamp))
{
// Konflikt: anderes Gerät hat dieselbe Entity kürzlich geändert
var recent = col.FindOne(e =>
e.EntityType == evt.EntityType &&
e.EntityId == evt.EntityId &&
e.DeviceId != evt.DeviceId &&
e.Timestamp > evt.Timestamp.AddSeconds(-30));
if (recent is not null)
{
conflictIds.Add(evt.EventId);
continue;
}
col.Insert(new ServerEvent
{
EventId = evt.EventId,
DeviceId = evt.DeviceId,
DeviceType = evt.DeviceType,
Timestamp = evt.Timestamp,
ClientSequenceNr = evt.SequenceNr,
ServerSequenceNr = ++serverSeq,
EntityType = evt.EntityType,
EntityId = evt.EntityId,
Operation = evt.Operation,
Payload = evt.Payload, // verschlüsselt Server liest nicht
});
}
return new PushResponse
{
Success = true,
ServerSequenceNr = serverSeq,
ConflictingEventIds = conflictIds,
};
}
// ── Pull: neue Events für Client bereitstellen ────────────────────────────
public PullResponse Pull(string userId, long since, string requestingDeviceId)
{
var db = GetDb(userId);
var col = db.GetCollection<ServerEvent>("events");
// Nur Events anderer Geräte zurückgeben
var events = col
.Find(e => e.ServerSequenceNr > since && e.DeviceId != requestingDeviceId)
.OrderBy(e => e.ServerSequenceNr)
.Take(500)
.Select(e => new SyncEvent
{
EventId = e.EventId,
DeviceId = e.DeviceId,
DeviceType = e.DeviceType,
Timestamp = e.Timestamp,
SequenceNr = e.ServerSequenceNr,
EntityType = e.EntityType,
EntityId = e.EntityId,
Operation = e.Operation,
Payload = e.Payload,
})
.ToList();
return new PullResponse
{
Events = events,
ServerSequenceNr = GetLastSequenceNr(col),
};
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private LiteDatabase GetDb(string userId)
{
lock (_lock)
{
if (!_dbs.TryGetValue(userId, out var db))
{
// Sanitize userId für Dateinamen
var safeName = string.Concat(userId
.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_'));
var path = Path.Combine(_dataPath, $"{safeName}.db");
db = new LiteDatabase(path);
_dbs[userId] = db;
}
return db;
}
}
private static long GetLastSequenceNr(ILiteCollection<ServerEvent> col)
{
var last = col.FindOne(Query.All(nameof(ServerEvent.ServerSequenceNr), Query.Descending));
return last?.ServerSequenceNr ?? 0;
}
}
// Internes Dokument im Server-EventStore
internal class ServerEvent
{
public Guid EventId { get; set; }
public string DeviceId { get; set; } = "";
public DeviceType DeviceType { get; set; }
public DateTime Timestamp { get; set; }
public long ClientSequenceNr { get; set; }
public long ServerSequenceNr { get; set; }
public string EntityType { get; set; } = "";
public string EntityId { get; set; } = "";
public string Operation { get; set; } = "";
public string Payload { get; set; } = ""; // AES-256 Server liest nicht
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<!-- Für Docker: kein self-contained nötig, Runtime im Container -->
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Alle Microsoft-Pakete auf 9.0.x pinnen -->
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="LiteDB" />
<PackageReference Include="System.Text.Json" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,50 @@
using LiteDB;
using LehrerApp.Sync.Models;
namespace LehrerApp.Api;
/// <summary>
/// Speichert Klartext-Events der WebApp/Companion im EventStore.
/// Diese werden vom Desktop-Client beim nächsten Pull abgeholt
/// und wie normale Events angewendet nur ohne Entschlüsselung.
///
/// Technisch gesehen ist das nur eine dünne Schicht über dem
/// bestehenden EventStore Plain-Events werden einfach mit
/// DeviceType.Companion markiert und landen im selben Stream.
/// </summary>
public class PlainEventStore
{
private readonly EventStore _eventStore;
public PlainEventStore(EventStore eventStore)
{
_eventStore = eventStore;
}
public PlainPushResponse Push(string userId, List<PlainSyncEvent> events)
{
// Plain-Events in normale SyncEvents umwandeln
// DeviceType.Companion → Desktop-Client entschlüsselt nicht
var syncEvents = events.Select(e => new SyncEvent
{
EventId = e.EventId,
DeviceId = e.DeviceId,
DeviceType = DeviceType.Companion, // ← Signal: kein Decrypt nötig
Timestamp = e.Timestamp,
SequenceNr = 0, // vom EventStore vergeben
EntityType = e.EntityType,
EntityId = e.EntityId,
Operation = e.Operation,
Payload = e.Payload, // Klartext JSON
}).ToList();
var result = _eventStore.Push(userId, syncEvents);
return new PlainPushResponse
{
Success = result.Success,
ServerSequenceNr = result.ServerSequenceNr,
RejectedEventIds = result.ConflictingEventIds,
};
}
}

57
LehrerApp.Api/Program.cs Normal file
View File

@@ -0,0 +1,57 @@
using System.Text;
using LehrerApp.Api;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
// ── Kestrel ───────────────────────────────────────────────────────────────────
builder.WebHost.UseKestrel(options =>
{
var port = builder.Configuration.GetValue<int>("Api:Port", 5000);
options.ListenAnyIP(port);
});
// ── JWT Auth ──────────────────────────────────────────────────────────────────
var jwtSecret = builder.Configuration["JWT_SECRET"]
?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert.");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtSecret)),
ValidateIssuer = false,
ValidateAudience = false,
ClockSkew = TimeSpan.FromMinutes(5),
};
});
builder.Services.AddAuthorization();
// ── Services ──────────────────────────────────────────────────────────────────
var dataPath = builder.Configuration["Api:DataPath"] ?? "./data";
builder.Services.AddSingleton<EventStore>(_ => new EventStore(dataPath));
builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(dataPath));
builder.Services.AddSingleton<ReadableSnapshotStore>(
_ => new ReadableSnapshotStore(dataPath));
builder.Services.AddSingleton<PlainEventStore>(sp =>
new PlainEventStore(sp.GetRequiredService<EventStore>()));
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// ── Endpoints ─────────────────────────────────────────────────────────────────
app.MapAuthEndpoints(jwtSecret);
app.MapSyncEndpoints();
app.MapSnapshotEndpoints();
app.MapReadableSnapshotEndpoints();
app.MapPlainSyncEndpoints();
app.Run();

View File

@@ -0,0 +1,72 @@
using System.Text.Json;
using LehrerApp.Core.Models;
namespace LehrerApp.Api;
/// <summary>
/// Server-seitiger Speicher für lesbare Snapshots.
/// Eine JSON-Datei pro User wird bei jedem Export überschrieben.
/// Kein Ablauf-Datum der letzte Stand bleibt bis zum nächsten Export.
/// </summary>
public class ReadableSnapshotStore
{
private readonly string _dataPath;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
public ReadableSnapshotStore(string dataPath)
{
_dataPath = Path.Combine(dataPath, "readable");
Directory.CreateDirectory(_dataPath);
}
// ── Speichern ─────────────────────────────────────────────────────────────
public void Store(string userId, ReadableSnapshot snapshot)
{
var path = SnapshotPath(userId);
var json = JsonSerializer.Serialize(snapshot, JsonOptions);
File.WriteAllText(path, json);
}
// ── Laden ─────────────────────────────────────────────────────────────────
public ReadableSnapshot? Load(string userId)
{
var path = SnapshotPath(userId);
if (!File.Exists(path)) return null;
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<ReadableSnapshot>(json, JsonOptions);
}
catch
{
return null;
}
}
/// <summary>
/// Gibt nur die Metadaten zurück für schnelle Freshness-Prüfung.
/// </summary>
public SnapshotMeta? LoadMeta(string userId)
{
var snapshot = Load(userId);
return snapshot?.Meta;
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private string SnapshotPath(string userId)
{
// Sanitize userId
var safe = string.Concat(
userId.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_'));
return Path.Combine(_dataPath, $"{safe}.json");
}
}

View File

@@ -0,0 +1,155 @@
using LiteDB;
using LehrerApp.Sync.Models;
namespace LehrerApp.Api;
/// <summary>
/// Server-seitiger Snapshot-Speicher.
/// Speichert:
/// - EncryptedPayload: mit Sync-Schlüssel verschlüsselte LiteDB
/// - EncryptedSyncKey: mit Code-Key (PBKDF2) verschlüsselter Sync-Schlüssel
///
/// Der Server versteht beides nicht er reicht es nur weiter.
/// TTL: 24h, Einmal-Verwendung.
/// </summary>
public class SnapshotStore : IDisposable
{
private readonly LiteDatabase _db;
private readonly ILiteCollection<SnapshotEntry> _col;
private readonly Timer _cleanupTimer;
private static readonly string[] Animals =
[
"TIGER", "ADLER", "DACHS", "LUCHS", "FALKE",
"IGEL", "ELCH", "FUCHS", "RABE", "WOLF",
"BISON", "LAMM", "EULE", "BIBER", "STORCH",
"HIRSCH","OTTER", "MARDER","KRANICH","LACHS",
];
private static readonly string[] Colors =
[
"BLAU", "GRUEN", "ROT", "GOLD", "GRAU",
"CYAN", "ROSA", "LILA", "SAND", "MINT",
"SMARAGD","KORALLE","INDIGO","AMBER","JADE",
];
public SnapshotStore(string dataPath)
{
Directory.CreateDirectory(dataPath);
_db = new LiteDatabase(Path.Combine(dataPath, "snapshots.db"));
_col = _db.GetCollection<SnapshotEntry>("snapshots");
_col.EnsureIndex(x => x.Code);
_col.EnsureIndex(x => x.ExpiresAt);
_cleanupTimer = new Timer(
_ => Cleanup(),
null,
TimeSpan.FromHours(1),
TimeSpan.FromHours(1));
}
// ── Upload ────────────────────────────────────────────────────────────────
/// <summary>
/// Speichert Snapshot und verschlüsselten Sync-Schlüssel.
/// Wenn für denselben User bereits ein Snapshot existiert, wird er ersetzt.
/// </summary>
public SnapshotUploadResponse Store(
string userId,
SnapshotUploadRequest request)
{
// Alten Snapshot desselben Users überschreiben
_col.DeleteMany(e => e.UserId == userId);
var code = GenerateCode();
var entry = new SnapshotEntry
{
Code = code,
UserId = userId,
EncryptedPayload = request.EncryptedPayload,
EncryptedSyncKey = request.EncryptedSyncKey,
SourceDeviceType = request.DeviceType,
CreatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddHours(24),
};
_col.Insert(entry);
return new SnapshotUploadResponse
{
Code = code,
ExpiresAt = entry.ExpiresAt,
};
}
// ── Download (Einmal-Verwendung) ──────────────────────────────────────────
public SnapshotDownloadResponse? Retrieve(string userId, string code)
{
var entry = _col.FindOne(e =>
e.UserId == userId &&
e.Code == code.ToUpperInvariant());
if (entry is null) return null;
if (entry.ExpiresAt < DateTime.UtcNow)
{
_col.Delete(entry.Id);
return null;
}
// Einmal-Verwendung: sofort löschen
_col.Delete(entry.Id);
return new SnapshotDownloadResponse
{
EncryptedPayload = entry.EncryptedPayload,
EncryptedSyncKey = entry.EncryptedSyncKey,
CreatedAt = entry.CreatedAt,
SourceDeviceType = entry.SourceDeviceType,
};
}
// ── Bereinigung ───────────────────────────────────────────────────────────
private void Cleanup()
{
var now = DateTime.UtcNow;
_col.DeleteMany(e => e.ExpiresAt < now);
}
// ── Code-Generierung ──────────────────────────────────────────────────────
private static string GenerateCode()
{
var rng = Random.Shared;
var animal = Animals[rng.Next(Animals.Length)];
var number = rng.Next(10, 99);
var color = Colors[rng.Next(Colors.Length)];
return $"{animal}-{number}-{color}";
}
public void Dispose()
{
_cleanupTimer.Dispose();
_db.Dispose();
}
}
internal class SnapshotEntry
{
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
public string Code { get; set; } = "";
public string UserId { get; set; } = "";
public string EncryptedPayload { get; set; } = "";
/// <summary>
/// Sync-Schlüssel verschlüsselt mit PBKDF2(Code).
/// Leer wenn noch nicht gesetzt (zweistufiger Upload).
/// </summary>
public string EncryptedSyncKey { get; set; } = "";
public DeviceType SourceDeviceType { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime ExpiresAt { get; set; }
}

View File

@@ -0,0 +1,12 @@
{
"Api": {
"Port": 5000,
"DataPath": "./data"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}