init 1.0.0

This commit is contained in:
2026-06-19 00:42:00 +02:00
commit 5ca960746b
67 changed files with 3261 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
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) =>
{
if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password))
return Results.Unauthorized();
// TODO: Passwort gegen DB prüfen
return Results.Ok(new { token = Jwt(req.Username, secret), userId = req.Username });
});
app.MapPost("/api/auth/register", (RegisterRequest req) =>
{
if (req.Password.Length < 12)
return Results.BadRequest("Passwort mind. 12 Zeichen.");
// TODO: User anlegen, Passwort hashen (BCrypt)
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 });
});
}
// ── 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);
public record RegisterRequest(string Username, string Password, string DisplayName);
+84
View File
@@ -0,0 +1,84 @@
using LiteDB;
using LehrerApp.Sync.Models;
namespace LehrerApp.Api;
/// <summary>
/// Append-only Event-Log pro User. Server versteht Payload nicht.
/// </summary>
public class EventStore(string dataPath) : IDisposable
{
private readonly Dictionary<string, LiteDatabase> _dbs = new();
private readonly Lock _lock = new();
public PushResponse Push(string userId, List<SyncEvent> events)
{
var col = GetCol(userId);
var seq = LastSeq(col);
var rejects = new List<Guid>();
foreach (var e in events.OrderBy(e => e.Timestamp))
{
var recent = col.FindOne(x =>
x.EntityType == e.EntityType && x.EntityId == e.EntityId &&
x.DeviceId != e.DeviceId && x.Timestamp > e.Timestamp.AddSeconds(-30));
if (recent is not null) { rejects.Add(e.EventId); continue; }
col.Insert(new ServerEvent { EventId = e.EventId, DeviceId = e.DeviceId,
DeviceType = e.DeviceType, Timestamp = e.Timestamp,
ClientSeq = e.SequenceNr, ServerSeq = ++seq,
EntityType = e.EntityType, EntityId = e.EntityId,
Operation = e.Operation, Payload = e.Payload });
}
return new() { Success = true, ServerSequenceNr = seq, ConflictingEventIds = rejects };
}
public PullResponse Pull(string userId, long since, string requestingDeviceId)
{
var col = GetCol(userId);
var events = col.Find(e => e.ServerSeq > since && e.DeviceId != requestingDeviceId)
.OrderBy(e => e.ServerSeq).Take(500)
.Select(e => new SyncEvent { EventId = e.EventId, DeviceId = e.DeviceId,
DeviceType = e.DeviceType, Timestamp = e.Timestamp,
SequenceNr = e.ServerSeq, EntityType = e.EntityType,
EntityId = e.EntityId, Operation = e.Operation, Payload = e.Payload })
.ToList();
return new() { Events = events, ServerSequenceNr = LastSeq(col) };
}
private ILiteCollection<ServerEvent> GetCol(string userId)
{
lock (_lock)
{
if (!_dbs.TryGetValue(userId, out var db))
{
var safe = string.Concat(userId.Where(c => char.IsLetterOrDigit(c) || c == '-'));
db = new LiteDatabase(Path.Combine(dataPath, $"{safe}.db"));
_dbs[userId] = db;
}
var col = db.GetCollection<ServerEvent>("events");
col.EnsureIndex(x => x.ServerSeq);
return col;
}
}
private static long LastSeq(ILiteCollection<ServerEvent> col)
{
var last = col.FindOne(Query.All(nameof(ServerEvent.ServerSeq), Query.Descending));
return last?.ServerSeq ?? 0;
}
public void Dispose() { foreach (var db in _dbs.Values) db.Dispose(); }
}
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 ClientSeq { get; set; }
public long ServerSeq { get; set; }
public string EntityType { get; set; } = "";
public string EntityId { get; set; } = "";
public string Operation { get; set; } = "";
public string Payload { get; set; } = "";
}
+14
View File
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="LiteDB" />
<PackageReference Include="System.Text.Json" />
</ItemGroup>
</Project>
+34
View File
@@ -0,0 +1,34 @@
using LehrerApp.Sync.Models;
namespace LehrerApp.Api;
public class PlainEventStore(EventStore eventStore)
{
private static readonly HashSet<string> Allowed =
["Grade", "ExamResult", "WorkTask", "Lesson"];
public PlainPushResponse Push(string userId, List<PlainSyncEvent> events)
{
var permitted = events.Where(e => Allowed.Contains(e.EntityType)).ToList();
var rejected = events.Where(e => !Allowed.Contains(e.EntityType))
.Select(e => e.EventId).ToList();
if (permitted.Count == 0) return new() { Success = true, RejectedEventIds = rejected };
var syncEvents = permitted.Select(e => new SyncEvent
{
EventId = e.EventId, DeviceId = e.DeviceId,
DeviceType = DeviceType.Companion,
Timestamp = e.Timestamp, SequenceNr = 0,
EntityType = e.EntityType, EntityId = e.EntityId,
Operation = e.Operation, Payload = e.Payload,
}).ToList();
var result = eventStore.Push(userId, syncEvents);
return new()
{
Success = result.Success,
ServerSequenceNr = result.ServerSequenceNr,
RejectedEventIds = [.. result.ConflictingEventIds, .. rejected],
};
}
}
+42
View File
@@ -0,0 +1,42 @@
using System.Text;
using LehrerApp.Api;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseKestrel(o =>
{
var port = builder.Configuration.GetValue<int>("Api:Port", 5000);
o.ListenAnyIP(port);
});
var secret = builder.Configuration["JWT_SECRET"]
?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert.");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o => o.TokenValidationParameters = new()
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)),
ValidateIssuer = false, ValidateAudience = false,
ClockSkew = TimeSpan.FromMinutes(5),
});
builder.Services.AddAuthorization();
var data = builder.Configuration["Api:DataPath"] ?? "./data";
builder.Services.AddSingleton<EventStore>(_ => new EventStore(data));
builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(data));
builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data));
builder.Services.AddSingleton<PlainEventStore>(sp =>
new PlainEventStore(sp.GetRequiredService<EventStore>()));
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapAuthEndpoints(secret);
app.MapSyncEndpoints();
app.MapSnapshotEndpoints();
app.MapReadableSnapshotEndpoints();
app.MapPlainSyncEndpoints();
app.Run();
+18
View File
@@ -0,0 +1,18 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Api;
public class ReadableSnapshot
{
public DateTime ExportedAt { get; set; }
public ReadableSnapshotMeta Meta { get; set; } = new();
public List<LearningGroup> Groups { get; set; } = [];
public List<Student> Students { get; set; } = [];
public List<Enrollment> Enrollments { get; set; } = [];
}
public class ReadableSnapshotMeta
{
public int StudentCount { get; set; }
public int GroupCount { get; set; }
}
+23
View File
@@ -0,0 +1,23 @@
using System.Text.Json;
using LehrerApp.Core.Models;
namespace LehrerApp.Api;
public class ReadableSnapshotStore(string dataPath)
{
private readonly string _path = Path.Combine(dataPath, "readable");
private static readonly JsonSerializerOptions _opts = new() { WriteIndented = false, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
public void Store(string userId, ReadableSnapshot snap)
{
Directory.CreateDirectory(_path);
File.WriteAllText(FilePath(userId), JsonSerializer.Serialize(snap, _opts));
}
public ReadableSnapshot? Load(string userId)
{
var p = FilePath(userId);
return File.Exists(p) ? JsonSerializer.Deserialize<ReadableSnapshot>(File.ReadAllText(p), _opts) : null;
}
private string FilePath(string userId) =>
Path.Combine(_path, $"{string.Concat(userId.Where(c => char.IsLetterOrDigit(c) || c == '-'))}.json");
}
+64
View File
@@ -0,0 +1,64 @@
using LiteDB;
using LehrerApp.Sync.Models;
namespace LehrerApp.Api;
public class SnapshotStore(string dataPath) : IDisposable
{
private readonly LiteDatabase _db =
new(Path.Combine(dataPath, "snapshots.db"));
private readonly Timer _cleanup;
public SnapshotStore(string dataPath, bool unused = false) : this(dataPath)
{
Directory.CreateDirectory(dataPath);
_cleanup = new(_ => Clean(), null, TimeSpan.FromHours(1), TimeSpan.FromHours(1));
}
private ILiteCollection<SnapshotEntry> Col => _db.GetCollection<SnapshotEntry>("snap");
public SnapshotUploadResponse Store(string userId, SnapshotUploadRequest req)
{
Col.DeleteMany(e => e.UserId == userId);
var code = NewCode();
var entry = new SnapshotEntry { Code = code, UserId = userId,
EncryptedPayload = req.EncryptedPayload,
EncryptedSyncKey = req.EncryptedSyncKey,
SourceDeviceType = req.DeviceType,
CreatedAt = DateTime.UtcNow, ExpiresAt = DateTime.UtcNow.AddHours(24) };
Col.Insert(entry);
return new() { Code = code, ExpiresAt = entry.ExpiresAt };
}
public SnapshotDownloadResponse? Retrieve(string userId, string code)
{
var e = Col.FindOne(x => x.UserId == userId && x.Code == code.ToUpperInvariant());
if (e is null || e.ExpiresAt < DateTime.UtcNow) { if (e is not null) Col.Delete(e.Id); return null; }
Col.Delete(e.Id);
return new() { EncryptedPayload = e.EncryptedPayload, EncryptedSyncKey = e.EncryptedSyncKey,
CreatedAt = e.CreatedAt, SourceDeviceType = e.SourceDeviceType };
}
private void Clean() => Col.DeleteMany(e => e.ExpiresAt < DateTime.UtcNow);
private static string NewCode()
{
string[] animals = ["TIGER","ADLER","DACHS","LUCHS","FALKE","IGEL","ELCH","FUCHS","RABE","WOLF","BISON","LAMM","EULE","BIBER","STORCH"];
string[] colors = ["BLAU","GRUEN","ROT","GOLD","GRAU","CYAN","ROSA","LILA","SAND","MINT"];
return $"{animals[Random.Shared.Next(animals.Length)]}-{Random.Shared.Next(10,99)}-{colors[Random.Shared.Next(colors.Length)]}";
}
public void Dispose() { _cleanup?.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; } = "";
public string EncryptedSyncKey { get; set; } = "";
public DeviceType SourceDeviceType { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime ExpiresAt { get; set; }
}