diff --git a/BiStudent.Core/Contracts/Contracts.cs b/BiStudent.Core/Contracts/Contracts.cs index 04d230b..a95b8ef 100644 --- a/BiStudent.Core/Contracts/Contracts.cs +++ b/BiStudent.Core/Contracts/Contracts.cs @@ -9,7 +9,7 @@ public sealed record TokenResponse(string AccessToken, long ExpiresIn); public sealed record TeacherDto(int Id, string FullName, string Login); public sealed record GroupDto(int Id, string Name, int Students, int WithFace); -public sealed record StudentDto(int Id, string FullName, int GroupId, bool HasFace); +public sealed record StudentDto(int Id, string FullName, int GroupId, bool HasFace, bool FaceUnreadable = false); public sealed record CreateGroupRequest(string Name); public sealed record SaveStudentRequest(string FullName, int GroupId); @@ -59,3 +59,6 @@ public sealed record EnrollResultDto(bool Ok, string Message); public sealed record ImportResultDto(int Added, int Skipped, List Errors); public sealed record ErrorDto(string Message); + +public sealed record BackupFileDto(string Name, DateTime CreatedAt, long Size, bool Automatic); +public sealed record RestoreResultDto(bool Ok, string Message, int Students, int Marks, bool RestartRecommended); diff --git a/BiStudent.Server/Endpoints/ApiEndpoints.cs b/BiStudent.Server/Endpoints/ApiEndpoints.cs index 73b7a16..67484e8 100644 --- a/BiStudent.Server/Endpoints/ApiEndpoints.cs +++ b/BiStudent.Server/Endpoints/ApiEndpoints.cs @@ -81,10 +81,12 @@ public static void MapApi(this WebApplication app) await students.DeleteGroupAsync(id); return Results.NoContent(); }); - groups.MapGet("/{id:int}/students", async (int id, StudentService students) => + groups.MapGet("/{id:int}/students", async (int id, StudentService students, FaceSampleHealth health) => { var faces = await students.WithFaceAsync(id); - return (await students.ForGroupAsync(id)).Select(s => new StudentDto(s.Id, s.FullName, s.GroupId, faces.Contains(s.Id))).ToList(); + var unreadable = await health.UnreadableStudentsAsync(); + return (await students.ForGroupAsync(id)) + .Select(s => new StudentDto(s.Id, s.FullName, s.GroupId, faces.Contains(s.Id) && !unreadable.Contains(s.Id), unreadable.Contains(s.Id))).ToList(); }); var students = api.MapGroup("/students"); @@ -149,8 +151,23 @@ public static void MapApi(this WebApplication app) await EnsureSlotAsync(db, user, request.SlotId); return await recognition.RecognizeAsync(TeacherId(user), request.SlotId, request.Date, request.Image); }).WithMetadata(new RequestSizeLimitAttribute(4 * 1024 * 1024)); - api.MapGet("/recognition/status", (RecognitionService recognition) => - new EnrollResultDto(recognition.IsAvailable, recognition.Error ?? "Распознавание готово.")); + api.MapGet("/recognition/status", async (RecognitionService recognition, FaceSampleHealth health) => + { + if (!recognition.IsAvailable) return new EnrollResultDto(false, recognition.Error ?? "Распознавание недоступно."); + var unreadable = (await health.UnreadableStudentsAsync()).Count; + return new EnrollResultDto(true, unreadable == 0 ? "Распознавание готово." + : $"Образцы лиц {unreadable} студентов не расшифровываются (база перенесена без ключей шифрования). Зарегистрируйте их лица заново — история посещаемости сохранена."); + }); + + var admin = api.MapGroup("/admin").RequireAuthorization("BackupAdmin"); + admin.MapGet("/backups", (BackupService backups) => backups.List()); + admin.MapPost("/backups", async (BackupService backups, CancellationToken ct) => await backups.CreateAsync(automatic: false, ct)); + admin.MapGet("/backups/{name}", (string name, BackupService backups) => + backups.Resolve(name) is { } path + ? Results.File(path, "application/zip", Path.GetFileName(path)) + : Results.Json(new ErrorDto("Копия не найдена."), statusCode: StatusCodes.Status404NotFound)); + admin.MapPost("/restore", async (HttpRequest request, ClaimsPrincipal user, BackupService backups, SessionGeneration sessions, CancellationToken ct) => + await backups.RestoreAsync(request.Body, ct, () => sessions.IsCurrent(user))).WithMetadata(new RequestSizeLimitAttribute(200L * 1024 * 1024)); var journal = api.MapGroup("/journal"); journal.MapGet("/{groupId:int}", async (int groupId, DateOnly from, DateOnly to, string? subject, ClaimsPrincipal user, AttendanceService attendance) => @@ -168,14 +185,16 @@ public static void MapApi(this WebApplication app) }); } - private static async Task LoginAsync(LoginRequest request, AppDbContext db, IPasswordHasher hasher) + private static async Task LoginAsync(LoginRequest request, AppDbContext db, IPasswordHasher hasher, SessionGeneration sessions) { var teacher = await db.Teachers.SingleOrDefaultAsync(x => x.Login == request.Login.Trim()); if (teacher is null || hasher.VerifyHashedPassword(teacher, teacher.PasswordHash, request.Password) == PasswordVerificationResult.Failed) return Results.Json(new ErrorDto("Неверный логин или пароль."), statusCode: StatusCodes.Status401Unauthorized); var identity = new ClaimsIdentity([ new Claim(ClaimTypes.NameIdentifier, teacher.Id.ToString(CultureInfo.InvariantCulture)), - new Claim(ClaimTypes.Name, teacher.FullName) + new Claim(ClaimTypes.Name, teacher.FullName), + new Claim("bistudent:login", teacher.Login), + sessions.Claim() ], BearerTokenDefaults.AuthenticationScheme); return Results.SignIn(new ClaimsPrincipal(identity), authenticationScheme: BearerTokenDefaults.AuthenticationScheme); } diff --git a/BiStudent.Server/Program.cs b/BiStudent.Server/Program.cs index e063afd..e41c09e 100644 --- a/BiStudent.Server/Program.cs +++ b/BiStudent.Server/Program.cs @@ -24,7 +24,11 @@ .SetApplicationName("BiStudent"); builder.Services.AddAuthentication(BearerTokenDefaults.AuthenticationScheme) .AddBearerToken(options => options.BearerTokenExpiration = TimeSpan.FromHours(12)); -builder.Services.AddAuthorization(); +builder.Services.AddAuthorization(options => options.AddPolicy("BackupAdmin", policy => policy.RequireAssertion(context => +{ + var admins = builder.Configuration.GetSection("Admin:Logins").Get() ?? ["teacher"]; + return context.User.FindFirst("bistudent:login")?.Value is { } login && admins.Contains(login, StringComparer.OrdinalIgnoreCase); +}))); builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); builder.Services.AddProblemDetails(); @@ -38,6 +42,11 @@ builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(new StoragePaths(Path.GetFullPath(sqlite.DataSource), sqlite.ToString())); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Services.AddScoped(); var app = builder.Build(); var culture = CultureInfo.GetCultureInfo("ru-RU"); @@ -46,6 +55,31 @@ app.UseExceptionHandler(); app.UseAuthentication(); +app.Use(async (context, next) => +{ + var path = context.Request.Path; + // Операции с копиями сериализуются внутри BackupService; остальные запросы к API восстановление дожидается. + var tracked = path.StartsWithSegments("/api") && !path.StartsWithSegments("/api/admin"); + var backups = context.RequestServices.GetRequiredService(); + if (tracked && !backups.TryEnterRequest()) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + await context.Response.WriteAsJsonAsync(new BiStudent.Contracts.ErrorDto("Идёт восстановление резервной копии — повторите через минуту.")); + return; + } + try + { + if (context.User.Identity?.IsAuthenticated == true && !path.StartsWithSegments("/api/auth/login") && + !context.RequestServices.GetRequiredService().IsCurrent(context.User)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsJsonAsync(new BiStudent.Contracts.ErrorDto("Сессия завершена — войдите снова.")); + return; + } + await next(); + } + finally { if (tracked) backups.ExitRequest(); } +}); app.UseAuthorization(); app.MapHealthChecks("/health"); app.MapGet("/", () => Results.Text("BiStudent API. Клиент преподавателя — приложение BiStudent для Windows.", "text/plain; charset=utf-8")); diff --git a/BiStudent.Server/Services/BackupService.cs b/BiStudent.Server/Services/BackupService.cs new file mode 100644 index 0000000..b91a2de --- /dev/null +++ b/BiStudent.Server/Services/BackupService.cs @@ -0,0 +1,389 @@ +using System.IO.Compression; +using System.Text.Json; +using System.Text.RegularExpressions; +using BiStudent.Contracts; +using BiStudent.Server.Data; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace BiStudent.Server.Services; + +public sealed class BackupService(StoragePaths paths, IServiceScopeFactory scopes, RecognitionService recognition, SessionGeneration sessions, ILogger logger) +{ + private static readonly Regex BackupName = new(@"\Abistudent-[0-9]{8}-[0-9]{6}(-auto)?(-[0-9]+)?\.zip\z", RegexOptions.CultureInvariant); + private readonly SemaphoreSlim gate = new(1, 1); + private static readonly Regex KeyName = new(@"\Akeys/key-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\.xml\z", RegexOptions.CultureInvariant); + private const long MaxZipSize = 200L * 1024 * 1024; + private const long MaxDatabaseSize = 1024L * 1024 * 1024; + private const long MaxManifestSize = 64 * 1024; + private const long MaxKeySize = 64 * 1024; + private const int MaxEntries = 64; + private int maintenance; + private int inflight; + + public async Task CreateAsync(bool automatic, CancellationToken ct = default) + { + await gate.WaitAsync(ct); + try { return await CreateLockedAsync(automatic, ct); } + finally { gate.Release(); } + } + + private async Task CreateLockedAsync(bool automatic, CancellationToken ct) + { + Directory.CreateDirectory(paths.BackupsDirectory); + var stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss"); + var stem = $"bistudent-{stamp}{(automatic ? "-auto" : "")}"; + var name = $"{stem}.zip"; + for (var suffix = 2; File.Exists(Path.Combine(paths.BackupsDirectory, name)); suffix++) name = $"{stem}-{suffix}.zip"; + var target = Path.Combine(paths.BackupsDirectory, name); + var snapshot = Path.Combine(paths.BackupsDirectory, $"{Guid.NewGuid():N}.db.tmp"); + var temporary = target + ".tmp"; + try + { + using (var source = new SqliteConnection(paths.ConnectionString)) + { + await source.OpenAsync(ct); + using var command = source.CreateCommand(); + command.CommandText = $"VACUUM INTO '{snapshot.Replace("'", "''")}'"; + await command.ExecuteNonQueryAsync(ct); + } + var (students, marks) = await CountsAsync(snapshot, ct); + var keyFiles = Directory.Exists(paths.KeysDirectory) + ? Directory.EnumerateFiles(paths.KeysDirectory, "*.xml").Where(key => KeyName.IsMatch($"keys/{Path.GetFileName(key)}")).ToList() + : []; + if (new FileInfo(snapshot).Length > MaxDatabaseSize || keyFiles.Count + 2 > MaxEntries || + keyFiles.Any(key => new FileInfo(key).Length > MaxKeySize)) + throw new InvalidOperationException("Данные не помещаются в формат резервной копии (база больше 1 ГБ или слишком много ключей)."); + using (var stream = File.Create(temporary)) + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create)) + { + archive.CreateEntryFromFile(snapshot, "bistudent.db"); + foreach (var key in keyFiles) + archive.CreateEntryFromFile(key, $"keys/{Path.GetFileName(key)}"); + using var manifest = archive.CreateEntry("manifest.json").Open(); + await JsonSerializer.SerializeAsync(manifest, new { app = "BiStudent", format = 1, + createdAt = DateTime.UtcNow.ToString("O"), students, marks }, cancellationToken: ct); + } + if (new FileInfo(temporary).Length > MaxZipSize) + throw new InvalidOperationException("Копия получилась больше 200 МБ — её нельзя будет восстановить через приложение."); + File.Move(temporary, target); + var info = new FileInfo(target); + logger.LogInformation("Создана резервная копия {Name}.", name); + return new BackupFileDto(name, info.LastWriteTime, info.Length, automatic); + } + finally + { + TryDelete(snapshot); + TryDelete(temporary); + } + } + + private void TryDelete(string path) + { + try { File.Delete(path); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { logger.LogWarning(ex, "Не удалось удалить временный файл {Path}.", path); } + } + + public IReadOnlyList List() + { + if (!Directory.Exists(paths.BackupsDirectory)) return []; + var result = new List(); + foreach (var path in Directory.EnumerateFiles(paths.BackupsDirectory, "bistudent-*.zip")) + { + try + { + var info = new FileInfo(path); + if (BackupName.IsMatch(info.Name)) + result.Add(new BackupFileDto(info.Name, info.LastWriteTime, info.Length, info.Name.Contains("-auto", StringComparison.Ordinal))); + } + catch (FileNotFoundException) { } + } + return result.OrderByDescending(x => x.CreatedAt).ToList(); + } + + public string? Resolve(string name) + { + if (!BackupName.IsMatch(name)) return null; + var path = Path.Combine(paths.BackupsDirectory, name); + return File.Exists(path) ? path : null; + } + + public void Prune(int keepAutomatic = 7) + { + gate.Wait(); + try { PruneLocked(keepAutomatic); } + finally { gate.Release(); } + } + + private void PruneLocked(int keepAutomatic) + { + foreach (var file in List().Where(x => x.Automatic).Skip(Math.Max(0, keepAutomatic))) + File.Delete(Path.Combine(paths.BackupsDirectory, file.Name)); + } + + public async Task AutoBackupIfDueAsync(CancellationToken ct) + { + await gate.WaitAsync(ct); + try + { + if (Directory.Exists(paths.BackupsDirectory)) + foreach (var stale in Directory.EnumerateFiles(paths.BackupsDirectory, "*.tmp")) + if (File.GetLastWriteTimeUtc(stale) < DateTime.UtcNow.AddHours(-1)) TryDelete(stale); + var latest = List().FirstOrDefault(x => x.Automatic); + if (latest is not null && DateTime.Now - latest.CreatedAt < TimeSpan.FromHours(24)) return; + if (!File.Exists(paths.DatabasePath)) return; + using (var scope = scopes.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + if (!await db.Students.AnyAsync(ct)) return; + } + await CreateLockedAsync(true, ct); + PruneLocked(7); + } + finally { gate.Release(); } + } + + public async Task RestoreAsync(Stream zip, CancellationToken ct = default, Func? stillAuthorized = null) + { + await gate.WaitAsync(ct); + try + { + if (stillAuthorized?.Invoke() == false) throw new UnauthorizedAccessException("Сессия завершена — войдите снова."); + return await RestoreLockedAsync(zip, ct); + } + finally { gate.Release(); } + } + + /// Обычные API-запросы регистрируются здесь, чтобы восстановление дождалось их и не потеряло изменения. + public bool TryEnterRequest() + { + Interlocked.Increment(ref inflight); + if (Volatile.Read(ref maintenance) == 0) return true; + Interlocked.Decrement(ref inflight); + return false; + } + + public void ExitRequest() => Interlocked.Decrement(ref inflight); + + private async Task EnterMaintenanceAsync(CancellationToken ct) + { + Volatile.Write(ref maintenance, 1); + var deadline = DateTime.UtcNow.AddSeconds(30); + while (Volatile.Read(ref inflight) > 0) + { + if (DateTime.UtcNow > deadline) + { + Volatile.Write(ref maintenance, 0); + throw new InvalidOperationException("Сервер занят другими запросами — повторите восстановление через минуту."); + } + await Task.Delay(50, ct); + } + } + + private async Task RestoreLockedAsync(Stream zip, CancellationToken ct) + { + Directory.CreateDirectory(paths.BackupsDirectory); + var work = Path.Combine(paths.BackupsDirectory, $"{Guid.NewGuid():N}.restore.tmp"); + Directory.CreateDirectory(work); + var upload = Path.Combine(work, "upload.zip"); + var extracted = Path.Combine(work, "bistudent.db"); + var keysDirectory = Path.Combine(work, "keys"); + var replaced = false; + try + { + await using (var output = File.Create(upload)) + await CopyLimitedAsync(zip, output, MaxZipSize, "Файл копии больше 200 МБ.", ct); + var keys = new List(); + int students, marks; + try + { + await using var archiveStream = File.OpenRead(upload); + using var archive = new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: true); + if (archive.Entries.Count > MaxEntries) throw InvalidBackup(); + var entries = new Dictionary(StringComparer.Ordinal); + foreach (var entry in archive.Entries) + { + if (entry.FullName is not ("manifest.json" or "bistudent.db") && !KeyName.IsMatch(entry.FullName)) + throw InvalidBackup(); + if (!entries.TryAdd(entry.FullName, entry)) throw InvalidBackup(); + } + if (!entries.TryGetValue("manifest.json", out var manifest) || + !entries.TryGetValue("bistudent.db", out var database)) throw InvalidBackup(); + using (var manifestBytes = new MemoryStream()) + { + await using (var input = manifest.Open()) + await CopyLimitedAsync(input, manifestBytes, MaxManifestSize, null, ct); + try + { + using var json = JsonDocument.Parse(manifestBytes.ToArray()); + if (json.RootElement.ValueKind != JsonValueKind.Object || + !json.RootElement.TryGetProperty("app", out var app) || app.ValueKind != JsonValueKind.String || + app.GetString() != "BiStudent" || + !json.RootElement.TryGetProperty("format", out var format) || format.ValueKind != JsonValueKind.Number || + !format.TryGetInt32(out var version) || version != 1) throw InvalidBackup(); + } + catch (JsonException) { throw InvalidBackup(); } + } + await using (var output = File.Create(extracted)) + await using (var input = database.Open()) + await CopyLimitedAsync(input, output, MaxDatabaseSize, null, ct); + Directory.CreateDirectory(keysDirectory); + foreach (var entry in entries.Values.Where(x => KeyName.IsMatch(x.FullName))) + { + var staged = Path.Combine(keysDirectory, entry.Name); + await using (var output = File.Create(staged)) + await using (var input = entry.Open()) + await CopyLimitedAsync(input, output, MaxKeySize, null, ct); + keys.Add(staged); + } + (students, marks) = await ValidateAsync(extracted, ct); + } + catch (InvalidDataException) { throw InvalidBackup(); } + + await EnterMaintenanceAsync(ct); + var insurance = await CreateLockedAsync(false, ct); + Directory.CreateDirectory(paths.KeysDirectory); + var restart = false; + foreach (var staged in keys) + { + var target = Path.Combine(paths.KeysDirectory, Path.GetFileName(staged)); + if (File.Exists(target) && File.ReadAllBytes(target).AsSpan().SequenceEqual(File.ReadAllBytes(staged))) continue; + var temporary = target + ".tmp"; + File.Copy(staged, temporary, overwrite: true); + File.Move(temporary, target, overwrite: true); + restart = true; + } + sessions.Rotate(); + replaced = true; + using (var source = new SqliteConnection(ReadOnlyConnection(extracted))) + using (var destination = new SqliteConnection(paths.ConnectionString)) + { + source.Open(); + destination.Open(); + source.BackupDatabase(destination); + } + SqliteConnection.ClearAllPools(); + var message = $"Восстановлено: {students} студентов, {marks} отметок. Текущие данные сохранены в копии {insurance.Name}. Все пользователи должны войти заново." + + (restart ? " Перезапустите сервер, чтобы образцы лиц из копии расшифровались." : ""); + logger.LogInformation("Восстановлена резервная копия; страховочная копия {Name}.", insurance.Name); + return new RestoreResultDto(true, message, students, marks, restart); + } + finally + { + if (replaced) recognition.Invalidate(); + Volatile.Write(ref maintenance, 0); + try { Directory.Delete(work, recursive: true); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { logger.LogWarning(ex, "Не удалось удалить временную папку {Path}.", work); } + } + } + + private static async Task CopyLimitedAsync(Stream input, Stream output, long limit, string? message, CancellationToken ct) + { + var buffer = new byte[81920]; + long total = 0; + int read; + while ((read = await input.ReadAsync(buffer, ct)) != 0) + { + total += read; + if (total > limit) throw message is null ? InvalidBackup() : new InvalidOperationException(message); + await output.WriteAsync(buffer.AsMemory(0, read), ct); + } + } + + private static InvalidOperationException InvalidBackup() => new("Это не резервная копия BiStudent."); + private static string ReadOnlyConnection(string path) => new SqliteConnectionStringBuilder + { DataSource = path, Mode = SqliteOpenMode.ReadOnly, Pooling = false }.ToString(); + + private static async Task<(int Students, int Marks)> CountsAsync(string path, CancellationToken ct) + { + using var connection = new SqliteConnection(ReadOnlyConnection(path)); + await connection.OpenAsync(ct); + return (await CountAsync(connection, "Students", ct), await CountAsync(connection, "AttendanceMarks", ct)); + } + + private async Task<(int Students, int Marks)> ValidateAsync(string path, CancellationToken ct) + { + using var connection = new SqliteConnection(ReadOnlyConnection(path)); + await connection.OpenAsync(ct); + using var integrity = connection.CreateCommand(); + integrity.CommandText = "PRAGMA integrity_check"; + if ((string?)await integrity.ExecuteScalarAsync(ct) != "ok") throw InvalidBackup(); + var expected = await SchemaAsync(ReferenceConnection(), ct); + var actual = await SchemaAsync(connection, ct); + foreach (var (table, definition) in expected) + if (!actual.TryGetValue(table, out var found) || found != definition) + throw new InvalidOperationException($"Копия от несовместимой версии BiStudent: отличается таблица {table}."); + using var foreignKeys = connection.CreateCommand(); + foreignKeys.CommandText = "PRAGMA foreign_key_check"; + await using (var reader = await foreignKeys.ExecuteReaderAsync(ct)) + if (await reader.ReadAsync(ct)) throw new InvalidOperationException("В копии нарушены связи между таблицами."); + return (await CountAsync(connection, "Students", ct), await CountAsync(connection, "AttendanceMarks", ct)); + } + + private SqliteConnection ReferenceConnection() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + using var db = new AppDbContext(options); + db.Database.EnsureCreated(); + return connection; + } + + /// Описание схемы для сравнения: столбцы (имя, тип, NOT NULL, PK) и внешние ключи каждой таблицы. + private static async Task> SchemaAsync(SqliteConnection connection, CancellationToken ct) + { + var tables = new List(); + using (var list = connection.CreateCommand()) + { + list.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"; + await using var reader = await list.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) tables.Add(reader.GetString(0)); + } + var result = new Dictionary(StringComparer.Ordinal); + foreach (var table in tables) + { + var parts = new List(); + using (var columns = connection.CreateCommand()) + { + columns.CommandText = "SELECT name, upper(type), \"notnull\", pk FROM pragma_table_info($table) ORDER BY name"; + columns.Parameters.AddWithValue("$table", table); + await using var reader = await columns.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + parts.Add($"c:{reader.GetString(0)}:{reader.GetString(1)}:{reader.GetInt64(2)}:{reader.GetInt64(3)}"); + } + using (var keys = connection.CreateCommand()) + { + keys.CommandText = "SELECT \"table\", \"from\", \"to\", on_delete FROM pragma_foreign_key_list($table) ORDER BY \"from\""; + keys.Parameters.AddWithValue("$table", table); + await using var reader = await keys.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + parts.Add($"f:{reader.GetString(0)}:{reader.GetString(1)}:{(reader.IsDBNull(2) ? "" : reader.GetString(2))}:{reader.GetString(3)}"); + } + result[table] = string.Join("|", parts); + } + return result; + } + + private static async Task CountAsync(SqliteConnection connection, string table, CancellationToken ct) + { + using var command = connection.CreateCommand(); + command.CommandText = $"SELECT COUNT(*) FROM \"{table}\""; + return Convert.ToInt32(await command.ExecuteScalarAsync(ct)); + } + + public sealed class AutoBackupWorker(BackupService backups, ILogger logger) : BackgroundService + { + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + while (!stoppingToken.IsCancellationRequested) + { + try { await backups.AutoBackupIfDueAsync(stoppingToken); } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } + catch (Exception ex) { logger.LogError(ex, "Не удалось создать автоматическую резервную копию."); } + await Task.Delay(TimeSpan.FromHours(1), stoppingToken); + } + } + } +} diff --git a/BiStudent.Server/Services/FaceSampleHealth.cs b/BiStudent.Server/Services/FaceSampleHealth.cs new file mode 100644 index 0000000..a8083b3 --- /dev/null +++ b/BiStudent.Server/Services/FaceSampleHealth.cs @@ -0,0 +1,28 @@ +using System.Security.Cryptography; +using BiStudent.Server.Data; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; + +namespace BiStudent.Server.Services; + +public sealed class FaceSampleHealth(AppDbContext db, IDataProtectionProvider protection) +{ + public async Task> UnreadableStudentsAsync() + { + var protector = protection.CreateProtector("BiStudent.FaceSamples"); + var samples = await db.FaceSamples.AsNoTracking() + .Select(x => new { x.StudentId, x.ProtectedImage }).ToListAsync(); + var unreadable = new HashSet(); + foreach (var group in samples.GroupBy(x => x.StudentId)) + { + var readable = false; + foreach (var sample in group) + { + try { protector.Unprotect(sample.ProtectedImage); readable = true; break; } + catch (CryptographicException) { } + } + if (!readable) unreadable.Add(group.Key); + } + return unreadable; + } +} diff --git a/BiStudent.Server/Services/SessionGeneration.cs b/BiStudent.Server/Services/SessionGeneration.cs new file mode 100644 index 0000000..117b6e6 --- /dev/null +++ b/BiStudent.Server/Services/SessionGeneration.cs @@ -0,0 +1,54 @@ +using System.Security.Claims; +using System.Security.Cryptography; + +namespace BiStudent.Server.Services; + +/// Случайный идентификатор поколения сессий: хранится вне базы и меняется при восстановлении копии, +/// чтобы токены, выданные раньше (в том числе другим сервером с теми же ключами), перестали действовать. +public sealed class SessionGeneration(StoragePaths paths) +{ + public const string ClaimType = "bistudent:session"; + private readonly object gate = new(); + private string? current; + + private string FilePath => Path.Combine(paths.DataDirectory, "session.generation"); + + public string Current + { + get + { + lock (gate) + { + if (current is not null) return current; + var stored = File.Exists(FilePath) ? File.ReadAllText(FilePath).Trim() : ""; + if (stored.Length < 32) { stored = NewId(); Write(stored); } + return current = stored; + } + } + } + + /// Сохраняет новое поколение до изменения данных; при ошибке записи бросает исключение и поколение не меняется. + public void Rotate() + { + lock (gate) + { + var next = NewId(); + Write(next); + current = next; + } + } + + public Claim Claim() => new(ClaimType, Current); + + public bool IsCurrent(ClaimsPrincipal user) => user.FindFirst(ClaimType)?.Value == Current; + + private static string NewId() => Convert.ToHexString(RandomNumberGenerator.GetBytes(16)); + + private void Write(string value) + { + Directory.CreateDirectory(paths.DataDirectory); + var temporary = FilePath + ".tmp"; + File.WriteAllText(temporary, value); + File.Move(temporary, FilePath, overwrite: true); + } +} diff --git a/BiStudent.Server/Services/StoragePaths.cs b/BiStudent.Server/Services/StoragePaths.cs new file mode 100644 index 0000000..2d04fa2 --- /dev/null +++ b/BiStudent.Server/Services/StoragePaths.cs @@ -0,0 +1,8 @@ +namespace BiStudent.Server.Services; + +public sealed record StoragePaths(string DatabasePath, string ConnectionString) +{ + public string DataDirectory => Path.GetDirectoryName(Path.GetFullPath(DatabasePath))!; + public string KeysDirectory => Path.Combine(DataDirectory, "keys"); + public string BackupsDirectory => Path.Combine(DataDirectory, "backups"); +} diff --git a/BiStudent.Server/appsettings.json b/BiStudent.Server/appsettings.json index 5a4835f..acf807b 100644 --- a/BiStudent.Server/appsettings.json +++ b/BiStudent.Server/appsettings.json @@ -15,6 +15,9 @@ "SecondsPerManualMark": 6, "MinutesPerManualReport": 20 }, + "Admin": { + "Logins": [ "teacher" ] + }, "Demo": { "Login": "teacher", "Password": "teacher123", diff --git a/BiStudent.Tests/BackupTests.cs b/BiStudent.Tests/BackupTests.cs new file mode 100644 index 0000000..244ae2d --- /dev/null +++ b/BiStudent.Tests/BackupTests.cs @@ -0,0 +1,161 @@ +using System.IO.Compression; +using BiStudent.Contracts; +using BiStudent.Server.Data; +using BiStudent.Server.Services; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class BackupTests : IDisposable +{ + private readonly string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + private readonly ServiceProvider services; + private readonly RecognitionService recognition; + private readonly BackupService backups; + private readonly StoragePaths paths; + private readonly SessionGeneration sessions; + + public BackupTests() + { + Directory.CreateDirectory(root); + var database = Path.Combine(root, "bistudent.db"); + paths = new StoragePaths(database, new SqliteConnectionStringBuilder { DataSource = database }.ToString()); + Directory.CreateDirectory(paths.KeysDirectory); + File.WriteAllText(Path.Combine(paths.KeysDirectory, "key-11111111-2222-3333-4444-555555555555.xml"), ""); + services = new ServiceCollection().AddDbContext(options => options.UseSqlite(paths.ConnectionString)).BuildServiceProvider(); + using (var scope = services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + var teacher = new Teacher { Login = "teacher", FullName = "Ирина Иванова" }; + var group = new Group { Name = "10-А" }; + var student = new Student { FullName = "Анна", Group = group }; + var slot = new ScheduleSlot { Teacher = teacher, Group = group, Subject = "Алгебра", + DayOfWeek = DayOfWeek.Tuesday, Start = new TimeOnly(9, 0), End = new TimeOnly(10, 0), + ValidFrom = new DateOnly(2026, 9, 1) }; + db.AttendanceMarks.Add(new AttendanceMark { Slot = slot, Student = student, + Date = new DateOnly(2026, 9, 1), Status = AttendanceStatus.Present, Method = MarkMethod.Manual }); + db.SaveChanges(); + } + var config = new ConfigurationBuilder().Build(); + recognition = new RecognitionService(services.GetRequiredService(), + DataProtectionProvider.Create("tests"), config); + sessions = new SessionGeneration(paths); + backups = new BackupService(paths, services.GetRequiredService(), recognition, sessions, + NullLogger.Instance); + } + + [Fact] + public async Task CreateIncludesDatabaseManifestAndKeys() + { + var backup = await backups.CreateAsync(false); + Assert.Equal(backup.Name, Assert.Single(backups.List()).Name); + Assert.Equal(Path.Combine(paths.BackupsDirectory, backup.Name), backups.Resolve(backup.Name)); + Assert.Null(backups.Resolve("../x.zip")); + Assert.Null(backups.Resolve("other.zip")); + using var archive = ZipFile.OpenRead(backups.Resolve(backup.Name)!); + Assert.Contains(archive.Entries, x => x.FullName == "bistudent.db"); + Assert.Contains(archive.Entries, x => x.FullName == "manifest.json"); + Assert.Contains(archive.Entries, x => x.FullName == "keys/key-11111111-2222-3333-4444-555555555555.xml"); + } + + [Fact] + public async Task RestoreReturnsStudentAndKeepsSafetyCopy() + { + var generation = sessions.Current; + var backup = await backups.CreateAsync(false); + using (var scope = services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Students.Remove(await db.Students.SingleAsync()); + await db.SaveChangesAsync(); + } + await using var zip = File.OpenRead(backups.Resolve(backup.Name)!); + var result = await backups.RestoreAsync(zip); + Assert.True(result.Ok); + Assert.Equal(1, result.Students); + Assert.Equal(1, result.Marks); + Assert.False(result.RestartRecommended); + Assert.Equal(2, backups.List().Count); + Assert.NotEqual(generation, sessions.Current); + using var restoredScope = services.CreateScope(); + var restored = restoredScope.ServiceProvider.GetRequiredService(); + Assert.Equal("Анна", (await restored.Students.SingleAsync()).FullName); + } + + [Fact] + public async Task RestoreRejectsInvalidEntries() + { + foreach (var names in new[] { new[] { "manifest.json" }, new[] { "manifest.json", "bistudent.db", "../evil.txt" }, + new[] { "manifest.json", "bistudent.db", "keys/C:injected.xml" } }) + { + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + foreach (var name in names) + { + using var writer = new StreamWriter(archive.CreateEntry(name).Open()); + writer.Write(name == "manifest.json" ? "{\"app\":\"BiStudent\",\"format\":1}" : "x"); + } + stream.Position = 0; + await Assert.ThrowsAsync(() => backups.RestoreAsync(stream)); + } + } + + [Fact] + public async Task PruneKeepsNewestAutomaticAndManual() + { + var automatic = new List(); + for (var i = 0; i < 4; i++) + { + var backup = await backups.CreateAsync(true); + File.SetLastWriteTimeUtc(backups.Resolve(backup.Name)!, DateTime.UtcNow.AddHours(i - 4)); + automatic.Add(backup); + } + var manual = await backups.CreateAsync(false); + backups.Prune(2); + var remaining = backups.List(); + Assert.Equal(3, remaining.Count); + Assert.Contains(remaining, x => x.Name == manual.Name); + Assert.Contains(remaining, x => x.Name == automatic[2].Name); + Assert.Contains(remaining, x => x.Name == automatic[3].Name); + } + + [Fact] + public async Task RestoreRejectsDatabaseWithIncompatibleSchema() + { + var generation = sessions.Current; + var foreign = Path.Combine(root, "foreign.db"); + using (var connection = new SqliteConnection($"Data Source={foreign};Pooling=False")) + { + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "CREATE TABLE Teachers(x); CREATE TABLE Students(x); CREATE TABLE AttendanceMarks(x); CREATE TABLE FaceSamples(x); CREATE TABLE ScheduleSlots(x);"; + command.ExecuteNonQuery(); + } + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + archive.CreateEntryFromFile(foreign, "bistudent.db"); + using var writer = new StreamWriter(archive.CreateEntry("manifest.json").Open()); + writer.Write("{\"app\":\"BiStudent\",\"format\":1}"); + } + stream.Position = 0; + var error = await Assert.ThrowsAsync(() => backups.RestoreAsync(stream)); + Assert.Contains("несовместимой", error.Message); + Assert.Equal(generation, sessions.Current); + } + + public void Dispose() + { + recognition.Dispose(); + services.Dispose(); + SqliteConnection.ClearAllPools(); + Directory.Delete(root, recursive: true); + } +} diff --git a/BiStudent/Api/ApiClient.cs b/BiStudent/Api/ApiClient.cs index d3e0481..5028979 100644 --- a/BiStudent/Api/ApiClient.cs +++ b/BiStudent/Api/ApiClient.cs @@ -63,6 +63,20 @@ public async Task LoadMeAsync() public Task AutomationAsync() => GetAsync("api/settings/automation"); public Task RecognitionStatusAsync() => GetAsync("api/recognition/status"); + public Task> BackupsAsync() => GetAsync>("api/admin/backups"); + public Task CreateBackupAsync() => + RequestAsync(HttpMethod.Post, "api/admin/backups", timeout: LongTimeout); + public Task RestoreAsync(byte[] zip) => + RequestAsync(HttpMethod.Post, "api/admin/restore", new ByteArrayContent(zip) { Headers = { ContentType = new MediaTypeHeaderValue("application/zip") } }, timeout: TimeSpan.FromMinutes(5)); + + public async Task<(byte[] Content, string FileName)> DownloadBackupAsync(string name) + { + using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + using var response = await SendWithDeadlineAsync(HttpMethod.Get, $"api/admin/backups/{Uri.EscapeDataString(name)}", null, deadline.Token, CancellationToken.None); + try { return (await response.Content.ReadAsByteArrayAsync(deadline.Token), name); } + catch (Exception ex) when (ex is HttpRequestException or IOException) { throw NetworkError(ex); } + catch (OperationCanceledException ex) { throw TimeoutError(ex); } + } public Task DashboardAsync(CancellationToken cancellationToken = default) => RequestAsync(HttpMethod.Get, "api/dashboard", cancellationToken: cancellationToken); public Task ReportAsync() => GetAsync("api/report"); diff --git a/BiStudent/Pages/BackupDialog.cs b/BiStudent/Pages/BackupDialog.cs new file mode 100644 index 0000000..3973825 --- /dev/null +++ b/BiStudent/Pages/BackupDialog.cs @@ -0,0 +1,159 @@ +using System.Globalization; +using BiStudent.Contracts; + +namespace BiStudent; + +internal sealed class BackupDialog : DarkForm +{ + private static readonly CultureInfo Ru = CultureInfo.GetCultureInfo("ru-RU"); + private readonly ApiClient api; + private readonly DataGridView grid = Ui.Grid(); + private readonly Label error = Ui.Label("", 9.5f, Theme.Red); + private readonly ModernButton create = new() { Text = "Создать копию сейчас", Primary = true, Width = 188 }; + private readonly ModernButton download = new() { Text = "Скачать…", Width = 112 }; + private readonly ModernButton restore = new() { Text = "Восстановить из файла…", Width = 204 }; + private readonly ModernButton close = new() { Text = "Закрыть", Width = 100 }; + private bool busy; + + public BackupDialog(ApiClient api) + { + this.api = api; + Text = "Резервные копии"; + ClientSize = new Size(760, 560); + FormBorderStyle = FormBorderStyle.FixedDialog; + StartPosition = FormStartPosition.CenterParent; + MaximizeBox = false; + MinimizeBox = false; + ShowInTaskbar = false; + + var heading = new Panel { Dock = DockStyle.Fill, BackColor = Theme.Background }; + var title = Ui.Label("Резервные копии", 16, Theme.Text, FontStyle.Bold); + title.Dock = DockStyle.Top; + title.Height = 42; + var hint = Ui.Label("Сервер сам делает копию раз в сутки и хранит последние 7. Копия содержит ключи шифрования образцов лиц — храните файл как пароль.", 9.5f, Theme.Muted); + hint.TextAlign = ContentAlignment.TopLeft; + heading.Controls.Add(hint); + heading.Controls.Add(title); + + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "Дата", FillWeight = 45, SortMode = DataGridViewColumnSortMode.NotSortable }); + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "Тип", FillWeight = 25, SortMode = DataGridViewColumnSortMode.NotSortable }); + grid.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "Размер", FillWeight = 25, SortMode = DataGridViewColumnSortMode.NotSortable }); + grid.SelectionChanged += (_, _) => download.Enabled = !busy && SelectedBackup() is not null; + error.TextAlign = ContentAlignment.MiddleLeft; + + var buttons = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.RightToLeft, WrapContents = false, BackColor = Theme.Background, Padding = new Padding(0, 10, 0, 0) }; + buttons.Controls.Add(close); + buttons.Controls.Add(restore); + buttons.Controls.Add(download); + buttons.Controls.Add(create); + + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 4, Padding = new Padding(24, 16, 24, 16), BackColor = Theme.Background }; + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 102)); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 40)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 58)); + layout.Controls.Add(heading, 0, 0); + layout.Controls.Add(grid, 0, 1); + layout.Controls.Add(error, 0, 2); + layout.Controls.Add(buttons, 0, 3); + Controls.Add(layout); + + create.Click += async (_, _) => await RunAsync(async () => + { + await api.CreateBackupAsync(); + await LoadAsync(); + Ui.Toast(this, "Копия создана"); + }); + download.Click += async (_, _) => await RunAsync(DownloadAsync); + restore.Click += async (_, _) => await RunAsync(RestoreAsync); + close.Click += (_, _) => Close(); + Shown += async (_, _) => await RunAsync(LoadAsync); + } + + private BackupFileDto? SelectedBackup() => grid.SelectedRows.Count == 0 ? null : grid.SelectedRows[0].Tag as BackupFileDto; + + private async Task LoadAsync() + { + var files = await api.BackupsAsync(); + grid.Rows.Clear(); + foreach (var file in files) + { + var row = grid.Rows[grid.Rows.Add( + file.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm", Ru), + file.Automatic ? "авто" : "вручную", + FormatSize(file.Size))]; + row.Tag = file; + } + grid.ClearSelection(); + if (grid.Rows.Count > 0) grid.Rows[0].Selected = true; + download.Enabled = !busy && SelectedBackup() is not null; + } + + private static string FormatSize(long size) + { + var unit = size >= 1024 * 1024 ? "МБ" : "КБ"; + var value = size >= 1024 * 1024 ? size / (1024d * 1024) : size / 1024d; + return $"{value.ToString("0.#", Ru)} {unit}"; + } + + private async Task DownloadAsync() + { + var selected = SelectedBackup(); + if (selected is null) return; + var (content, fileName) = await api.DownloadBackupAsync(selected.Name); + using var dialog = new SaveFileDialog { Filter = "Архив ZIP (*.zip)|*.zip", FileName = Path.GetFileName(fileName), DefaultExt = "zip" }; + if (dialog.ShowDialog(this) != DialogResult.OK) return; + await File.WriteAllBytesAsync(dialog.FileName, content); + Ui.Toast(this, "Копия сохранена"); + } + + private async Task RestoreAsync() + { + using var dialog = new OpenFileDialog { Filter = "Архив ZIP (*.zip)|*.zip" }; + if (dialog.ShowDialog(this) != DialogResult.OK) return; + if (!Ui.Confirm(this, "Восстановить данные?", + "Текущие данные будут заменены данными из копии. Перед заменой сервер сохранит их в отдельную копию.", + "Восстановить", danger: true)) return; + var content = await File.ReadAllBytesAsync(dialog.FileName); + var result = await api.RestoreAsync(content); + Ui.Message(this, "Восстановление", result.Message + Environment.NewLine + Environment.NewLine + + "Сейчас приложение вернётся к окну входа."); + try { await LoadAsync(); } + catch (ApiException) { } + } + + private async Task RunAsync(Func action) + { + if (busy) return; + busy = true; + SetBusy(true); + error.Text = ""; + UseWaitCursor = true; + try { await action(); } + catch (ApiException ex) { error.Text = ex.Message; } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { error.Text = ex.Message; } + finally + { + busy = false; + if (!IsDisposed) + { + SetBusy(false); + UseWaitCursor = false; + } + } + } + + protected override void OnFormClosing(FormClosingEventArgs e) + { + if (busy && e.CloseReason == CloseReason.UserClosing) e.Cancel = true; + base.OnFormClosing(e); + } + + private void SetBusy(bool value) + { + create.Enabled = !value; + download.Enabled = !value && SelectedBackup() is not null; + restore.Enabled = !value; + close.Enabled = !value; + } +} diff --git a/BiStudent/Pages/GroupsPage.cs b/BiStudent/Pages/GroupsPage.cs index 6bcccfe..b24ef51 100644 --- a/BiStudent/Pages/GroupsPage.cs +++ b/BiStudent/Pages/GroupsPage.cs @@ -26,7 +26,9 @@ public GroupsPage(ApiClient api) : base(api, "Группы и студенты", var import = new ModernButton { Text = "Импорт CSV", Width = 140 }; var addGroup = new ModernButton { Text = "+ Группа", Width = 130 }; var addStudent = new ModernButton { Text = "+ Студент", Width = 140, Primary = true }; - Actions.Controls.Add(import); Actions.Controls.Add(addGroup); Actions.Controls.Add(addStudent); + var backups = new ModernButton { Text = "Резервные копии", Width = 170 }; + backups.Click += (_, _) => { using var dialog = new BackupDialog(Api); dialog.ShowDialog(FindForm()); }; + Actions.Controls.Add(backups); Actions.Controls.Add(import); Actions.Controls.Add(addGroup); Actions.Controls.Add(addStudent); import.Click += async (_, _) => await ImportAsync(); addGroup.Click += async (_, _) => await AddGroupAsync(); addStudent.Click += async (_, _) => await AddStudentAsync(); @@ -56,7 +58,7 @@ public GroupsPage(ApiClient api) : base(api, "Группы и студенты", { var student = SelectedStudent(); if (student is null) { e.Cancel = true; return; } - enrollItem.Text = student.HasFace ? "Обновить лицо" : "Зарегистрировать лицо"; + enrollItem.Text = student.HasFace || student.FaceUnreadable ? "Обновить лицо" : "Зарегистрировать лицо"; enrollItem.Enabled = recognitionAvailable; }; enrollItem.Click += async (_, _) => { if (SelectedStudent() is { } student) await EnrollAsync(student); }; @@ -167,11 +169,11 @@ private async Task LoadGroupAsync() var student = students[i]; var percent = 0d; var hasLessons = journal.Columns.Count > 0 && percentages.TryGetValue(student.Id, out percent); - var index = grid.Rows.Add(i + 1, student.FullName, student.HasFace ? "✓ зарегистрировано" : "нет", + var index = grid.Rows.Add(i + 1, student.FullName, student.HasFace ? "✓ зарегистрировано" : student.FaceUnreadable ? "⚠ переснять" : "нет", hasLessons ? UiFormat.Percent(percent) : "—"); var row = grid.Rows[index]; row.Tag = student; - row.Cells[2].Style.ForeColor = student.HasFace ? Theme.Green : Theme.Muted; + row.Cells[2].Style.ForeColor = student.HasFace ? Theme.Green : student.FaceUnreadable ? Theme.Yellow : Theme.Muted; row.Cells[3].Style.ForeColor = hasLessons ? UiFormat.PercentColor(percent) : Theme.Muted; } deleteGroup.Visible = students.Count == 0; diff --git a/README.md b/README.md index 12634ee..84aef4c 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,15 @@ GitHub Actions (`.github/workflows/ci.yml`) на каждый push и PR: - `BiStudent.exe --self-test` — проверяет без сервера модели распознавания, список камер и отрисовку окна входа. Результат пишется в `diagnostics/result.txt`. - `BiStudent.exe --screenshots http://localhost:5080 teacher teacher123` — входит на сервер и сохраняет скриншоты всех разделов в `diagnostics/`. +## Резервные копии и перенос на другой компьютер + +- Сервер сам делает копию раз в сутки и хранит последние 7 в `App_Data/backups` (в Docker — в томе `/data/backups`). +- Ручная копия, скачивание и восстановление — в клиенте: «Группы» → «Резервные копии». +- Копия — zip с согласованным снимком базы (`VACUUM INTO`), манифестом и ключами шифрования образцов лиц. **Храните файл как пароль:** по нему можно расшифровать биометрию. +- Перед восстановлением сервер сохраняет текущие данные в отдельную копию, поэтому восстановление можно откатить. +- Перенос на другой компьютер: скачайте копию, поднимите сервер на новом месте и восстановите её. Если ключи из копии новые для этого сервера, перезапустите его — иначе образцы лиц не расшифруются. +- Если база перенесена без ключей (например, скопирован только `bistudent.db`), посещаемость и расписание сохраняются, а у студентов с нерасшифровываемыми образцами в «Группах» будет пометка «⚠ переснять» — их лица нужно зарегистрировать заново. + ## Данные и приватность - Биометрия хранится только на сервере: образцы лица 112×112 зашифрованы ASP.NET Data Protection, полные кадры с камеры не сохраняются.