init 1.0.0
This commit is contained in:
@@ -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);
|
||||
@@ -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; } = "";
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user