Initial
This commit is contained in:
147
LehrerApp.Api/Endpoints/Endpoints.cs
Normal file
147
LehrerApp.Api/Endpoints/Endpoints.cs
Normal 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();
|
||||
115
LehrerApp.Api/Endpoints/ReadableSnapshotEndpoints.cs
Normal file
115
LehrerApp.Api/Endpoints/ReadableSnapshotEndpoints.cs
Normal 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
144
LehrerApp.Api/EventStore.cs
Normal 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
|
||||
}
|
||||
19
LehrerApp.Api/LehrerApp.Api.csproj
Normal file
19
LehrerApp.Api/LehrerApp.Api.csproj
Normal 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>
|
||||
50
LehrerApp.Api/PlainEventStore.cs
Normal file
50
LehrerApp.Api/PlainEventStore.cs
Normal 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
57
LehrerApp.Api/Program.cs
Normal 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();
|
||||
72
LehrerApp.Api/ReadableSnapshotStore.cs
Normal file
72
LehrerApp.Api/ReadableSnapshotStore.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
155
LehrerApp.Api/SnapshotStore.cs
Normal file
155
LehrerApp.Api/SnapshotStore.cs
Normal 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; }
|
||||
}
|
||||
12
LehrerApp.Api/appsettings.json
Normal file
12
LehrerApp.Api/appsettings.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"Api": {
|
||||
"Port": 5000,
|
||||
"DataPath": "./data"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user