From 8fb645f97623b3e327bc8a7499721f939aca8eb2 Mon Sep 17 00:00:00 2001 From: Ligreus Date: Sun, 11 Oct 2026 15:01:36 +0500 Subject: [PATCH] Back up and restore the server data from the client Daily automatic backups (last 7 kept) and manual backup, download and restore from the Groups page. A backup is a zip with a consistent VACUUM INTO snapshot, a manifest and the Data Protection keys, so face samples survive a move to another computer. Restore waits for running requests, keeps a safety copy, replaces the live database, checks the schema against the current model and revokes existing sessions. Backup endpoints require an admin login. Students whose samples cannot be decrypted are flagged for re-enrollment. Fixes #10 --- BiStudent.Core/Contracts/Contracts.cs | 5 +- BiStudent.Server/Endpoints/ApiEndpoints.cs | 31 +- BiStudent.Server/Program.cs | 36 +- BiStudent.Server/Services/BackupService.cs | 389 ++++++++++++++++++ BiStudent.Server/Services/FaceSampleHealth.cs | 28 ++ .../Services/SessionGeneration.cs | 54 +++ BiStudent.Server/Services/StoragePaths.cs | 8 + BiStudent.Server/appsettings.json | 3 + BiStudent.Tests/BackupTests.cs | 161 ++++++++ BiStudent/Api/ApiClient.cs | 14 + BiStudent/Pages/BackupDialog.cs | 159 +++++++ BiStudent/Pages/GroupsPage.cs | 10 +- README.md | 9 + 13 files changed, 895 insertions(+), 12 deletions(-) create mode 100644 BiStudent.Server/Services/BackupService.cs create mode 100644 BiStudent.Server/Services/FaceSampleHealth.cs create mode 100644 BiStudent.Server/Services/SessionGeneration.cs create mode 100644 BiStudent.Server/Services/StoragePaths.cs create mode 100644 BiStudent.Tests/BackupTests.cs create mode 100644 BiStudent/Pages/BackupDialog.cs 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, полные кадры с камеры не сохраняются.