Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion BiStudent.Core/Contracts/Contracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -59,3 +59,6 @@ public sealed record EnrollResultDto(bool Ok, string Message);

public sealed record ImportResultDto(int Added, int Skipped, List<string> 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);
31 changes: 25 additions & 6 deletions BiStudent.Server/Endpoints/ApiEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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) =>
Expand All @@ -168,14 +185,16 @@ public static void MapApi(this WebApplication app)
});
}

private static async Task<IResult> LoginAsync(LoginRequest request, AppDbContext db, IPasswordHasher<Teacher> hasher)
private static async Task<IResult> LoginAsync(LoginRequest request, AppDbContext db, IPasswordHasher<Teacher> 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);
}
Expand Down
36 changes: 35 additions & 1 deletion BiStudent.Server/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>() ?? ["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();
Expand All @@ -38,6 +42,11 @@
builder.Services.AddScoped<SeedService>();
builder.Services.AddSingleton<AttendanceNotifier>();
builder.Services.AddSingleton<RecognitionService>();
builder.Services.AddSingleton(new StoragePaths(Path.GetFullPath(sqlite.DataSource), sqlite.ToString()));
builder.Services.AddSingleton<SessionGeneration>();
builder.Services.AddSingleton<BackupService>();
builder.Services.AddHostedService<BackupService.AutoBackupWorker>();
builder.Services.AddScoped<FaceSampleHealth>();

var app = builder.Build();
var culture = CultureInfo.GetCultureInfo("ru-RU");
Expand All @@ -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<BackupService>();
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<SessionGeneration>().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"));
Expand Down
Loading
Loading