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,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; }
}