diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73d0609..0bc7013 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,28 @@ jobs: $result = Join-Path $exe.DirectoryName 'diagnostics/result.txt' if ($p.ExitCode -ne 0 -or (Get-Content $result) -ne 'OK') { Get-Content (Join-Path $exe.DirectoryName 'self-test-error.txt') -ErrorAction SilentlyContinue; exit 1 } + firebase: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + - uses: actions/setup-node@v5 + with: + node-version: '24' + - name: Firebase Emulator tests + env: + FIREBASE_AUTH_EMULATOR_HOST: 127.0.0.1:9099 + FIRESTORE_EMULATOR_HOST: 127.0.0.1:8080 + run: > + npx --yes firebase-tools@14 emulators:exec --only auth,firestore --project demo-bistudent + "dotnet test BiStudent.Tests -- --filter-class BiStudent.Tests.FirebaseEmulatorTests --filter-class BiStudent.Tests.FirebaseSyncScenarioTests" + docker: runs-on: ubuntu-24.04 needs: build-test diff --git a/BiStudent.Client.Tests/PagesSmokeTests.cs b/BiStudent.Client.Tests/PagesSmokeTests.cs index afe3f58..4ed63a0 100644 --- a/BiStudent.Client.Tests/PagesSmokeTests.cs +++ b/BiStudent.Client.Tests/PagesSmokeTests.cs @@ -110,6 +110,7 @@ [new AtRiskStudentDto(101, "Абдиев Артур", "23-ПО-103-4р-1", 62.5, server.On(HttpMethod.Get, "api/report", _ => FakeServer.Json(report)); server.On(HttpMethod.Get, "api/settings/automation", _ => FakeServer.Json(new AutomationSettingsDto(6, 20, 3))); server.On(HttpMethod.Get, "api/recognition/status", _ => FakeServer.Json(new EnrollResultDto(true, "Распознавание готово."))); + server.On(HttpMethod.Get, "api/sync/status", _ => FakeServer.Json(new SyncStatusDto(true, "Ok", DateTimeOffset.Now, 0, null))); server.On(HttpMethod.Get, "api/schedule/day", _ => FakeServer.Json(new List { lesson, later })); server.On(HttpMethod.Get, "api/schedule/week", _ => FakeServer.Json(new List { lesson, later })); server.On(HttpMethod.Get, "api/schedule/parity", _ => FakeServer.Json(WeekParity.Odd)); diff --git a/BiStudent.Core/Contracts/Contracts.cs b/BiStudent.Core/Contracts/Contracts.cs index 04b8073..8375375 100644 --- a/BiStudent.Core/Contracts/Contracts.cs +++ b/BiStudent.Core/Contracts/Contracts.cs @@ -16,6 +16,8 @@ public sealed record TokenResponse(string AccessToken, long ExpiresIn, string? R public sealed record TeacherDto(int Id, string FullName, string Login, bool IsAdmin = false, bool MustChangePassword = false, int PendingFaceSubmissions = 0); public sealed record ChangePasswordRequest(string CurrentPassword, string NewPassword); +public sealed record SyncStatusDto(bool Enabled, string State, DateTimeOffset? LastSuccessAt, int Pending, string? Error); + public sealed record TeacherAccountDto(int Id, string Login, string FullName, bool IsAdmin, bool IsActive, bool MustChangePassword, DateTime CreatedAt, DateTime? LastLoginAt); public sealed record SaveTeacherRequest(string Login, string FullName, bool IsAdmin, bool IsActive); public sealed record TemporaryPasswordDto(string Login, string Password); diff --git a/BiStudent.Server/Data/AppDbContext.cs b/BiStudent.Server/Data/AppDbContext.cs index 73a028f..612f782 100644 --- a/BiStudent.Server/Data/AppDbContext.cs +++ b/BiStudent.Server/Data/AppDbContext.cs @@ -21,6 +21,12 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon public DbSet EnrollmentCodes => Set(); public DbSet FaceSubmissions => Set(); public DbSet FaceSubmissionImages => Set(); + public DbSet SyncLinks => Set(); + public DbSet SyncOutbox => Set(); + public DbSet SyncAliases => Set(); + + /// Идёт применение изменений из Firestore: не ставить их в очередь и не добавлять членство автоматически. + public bool ApplyingRemote { get; set; } public IQueryable MembershipsOn(DateOnly date) => GroupMemberships.Where(x => x.From <= date && (x.To == null || x.To >= date)); @@ -30,6 +36,7 @@ public IQueryable MembershipsDuring(DateOnly from, DateOnly to) private void AddInitialMemberships() { + if (ApplyingRemote) return; foreach (var entry in ChangeTracker.Entries().Where(x => x.State == EntityState.Added)) { var student = entry.Entity; @@ -61,6 +68,11 @@ public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, Cance protected override void OnModelCreating(ModelBuilder model) { model.Entity().HasIndex(x => x.Login).IsUnique(); + model.Entity().HasIndex(x => x.FirebaseUid).IsUnique(); + model.Entity().HasIndex(x => new { x.Table, x.LocalId }).IsUnique(); + model.Entity().HasIndex(x => new { x.Table, x.CloudId }).IsUnique(); + model.Entity().HasIndex(x => new { x.Table, x.CloudId }).IsUnique(); + model.Entity().ToTable("SyncOutbox").HasIndex(x => new { x.Table, x.LocalId }); model.Entity().HasIndex(x => x.Name).IsUnique(); model.Entity().HasKey(x => x.Key); model.Entity().HasIndex(x => new { x.SlotId, x.Date }).IsUnique(); diff --git a/BiStudent.Server/Data/Entities.cs b/BiStudent.Server/Data/Entities.cs index e7c554b..8410104 100644 --- a/BiStudent.Server/Data/Entities.cs +++ b/BiStudent.Server/Data/Entities.cs @@ -14,6 +14,10 @@ public sealed class Teacher public string SecurityStamp { get; set; } = Guid.NewGuid().ToString("N"); public DateTime CreatedAt { get; set; } = DateTime.Now; public DateTime? LastLoginAt { get; set; } + // Учётная запись Firebase Authentication, если она уже связана с этим преподавателем. + public string? FirebaseUid { get; set; } + // Секрет для первой привязки старой учётки к Firebase; хранится только локально, в облаке — его SHA-256. + public string? ClaimToken { get; set; } } public sealed class Group diff --git a/BiStudent.Server/Data/Migrations/20261020175237_FirebaseSync.Designer.cs b/BiStudent.Server/Data/Migrations/20261020175237_FirebaseSync.Designer.cs new file mode 100644 index 0000000..eed5ab2 --- /dev/null +++ b/BiStudent.Server/Data/Migrations/20261020175237_FirebaseSync.Designer.cs @@ -0,0 +1,881 @@ +// +using System; +using BiStudent.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace BiStudent.Server.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20261020175237_FirebaseSync")] + partial class FirebaseSync + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("BiStudent.Server.Data.AppSetting", b => + { + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Assessment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TeacherId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("TeacherId", "GroupId", "Subject"); + + b.ToTable("Assessments"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.AssessmentGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssessmentId") + .HasColumnType("INTEGER"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("AssessmentId", "StudentId") + .IsUnique(); + + b.ToTable("AssessmentGrades", t => + { + t.HasCheckConstraint("CK_AssessmentGrades_Score", "Score BETWEEN 1 AND 100"); + }); + }); + + modelBuilder.Entity("BiStudent.Server.Data.AttendanceMark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("ExcuseReason") + .HasColumnType("INTEGER"); + + b.Property("HasDocument") + .HasColumnType("INTEGER"); + + b.Property("MarkedAt") + .HasColumnType("TEXT"); + + b.Property("Method") + .HasColumnType("INTEGER"); + + b.Property("SlotId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("SlotId", "Date", "StudentId") + .IsUnique(); + + b.ToTable("AttendanceMarks"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.EnrollmentCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedByTeacherId") + .HasColumnType("INTEGER"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CreatedByTeacherId"); + + b.HasIndex("GroupId"); + + b.ToTable("EnrollmentCodes"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ProtectedImage") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.ToTable("FaceSamples"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnrollmentCodeId") + .HasColumnType("INTEGER"); + + b.Property("RejectReason") + .HasColumnType("TEXT"); + + b.Property("ReviewedAt") + .HasColumnType("TEXT"); + + b.Property("ReviewedByTeacherId") + .HasColumnType("INTEGER"); + + b.Property("SimilarScore") + .HasColumnType("REAL"); + + b.Property("SimilarTo") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EnrollmentCodeId"); + + b.HasIndex("StudentId"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("FaceSubmissions"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSubmissionImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ProtectedImage") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("SubmissionId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubmissionId"); + + b.ToTable("FaceSubmissionImages"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FinalGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TeacherId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("StudentId"); + + b.HasIndex("TeacherId", "GroupId", "Subject", "StudentId") + .IsUnique(); + + b.ToTable("FinalGrades", t => + { + t.HasCheckConstraint("CK_FinalGrades_Score", "Score BETWEEN 1 AND 100"); + }); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.GroupMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("From") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.Property("To") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("StudentId", "From") + .IsUnique(); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.LessonGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SlotId") + .HasColumnType("INTEGER"); + + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("SlotId", "Date", "StudentId") + .IsUnique(); + + b.ToTable("LessonGrades", t => + { + t.HasCheckConstraint("CK_LessonGrades_Score", "Score BETWEEN 1 AND 100"); + }); + }); + + modelBuilder.Entity("BiStudent.Server.Data.ScheduleSlot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("OneOffDate") + .HasColumnType("TEXT"); + + b.Property("Parity") + .HasColumnType("INTEGER"); + + b.Property("Room") + .HasColumnType("TEXT"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TeacherId") + .HasColumnType("INTEGER"); + + b.Property("ValidFrom") + .HasColumnType("TEXT"); + + b.Property("ValidTo") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("TeacherId"); + + b.ToTable("ScheduleSlots"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.SlotCancellation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SlotId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SlotId", "Date") + .IsUnique(); + + b.ToTable("SlotCancellations"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("Students"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.SyncAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanonicalCloudId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CloudId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Table") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Table", "CloudId") + .IsUnique(); + + b.ToTable("SyncAliases"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.SyncLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CloudId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CloudUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("LocalId") + .HasColumnType("INTEGER"); + + b.Property("Table") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Table", "CloudId") + .IsUnique(); + + b.HasIndex("Table", "LocalId") + .IsUnique(); + + b.ToTable("SyncLinks"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.SyncOutboxEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CloudId") + .HasColumnType("TEXT"); + + b.Property("Deleted") + .HasColumnType("INTEGER"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("LocalId") + .HasColumnType("INTEGER"); + + b.Property("QueuedAt") + .HasColumnType("TEXT"); + + b.Property("Table") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Table", "LocalId"); + + b.ToTable("SyncOutbox", (string)null); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Teacher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimToken") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FirebaseUid") + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("Login") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MustChangePassword") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FirebaseUid") + .IsUnique(); + + b.HasIndex("Login") + .IsUnique(); + + b.ToTable("Teachers"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Assessment", b => + { + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.AssessmentGrade", b => + { + b.HasOne("BiStudent.Server.Data.Assessment", "Assessment") + .WithMany("Grades") + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assessment"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.AttendanceMark", b => + { + b.HasOne("BiStudent.Server.Data.ScheduleSlot", "Slot") + .WithMany() + .HasForeignKey("SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Slot"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.EnrollmentCode", b => + { + b.HasOne("BiStudent.Server.Data.Teacher", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedByTeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedBy"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSample", b => + { + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany("Samples") + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSubmission", b => + { + b.HasOne("BiStudent.Server.Data.EnrollmentCode", "EnrollmentCode") + .WithMany() + .HasForeignKey("EnrollmentCodeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EnrollmentCode"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSubmissionImage", b => + { + b.HasOne("BiStudent.Server.Data.FaceSubmission", "Submission") + .WithMany("Images") + .HasForeignKey("SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Submission"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FinalGrade", b => + { + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Student"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.GroupMembership", b => + { + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany("Memberships") + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.LessonGrade", b => + { + b.HasOne("BiStudent.Server.Data.ScheduleSlot", "Slot") + .WithMany() + .HasForeignKey("SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Slot"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.ScheduleSlot", b => + { + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("BiStudent.Server.Data.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.SlotCancellation", b => + { + b.HasOne("BiStudent.Server.Data.ScheduleSlot", "Slot") + .WithMany("Cancellations") + .HasForeignKey("SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Slot"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Student", b => + { + b.HasOne("BiStudent.Server.Data.Group", "Group") + .WithMany("Students") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Assessment", b => + { + b.Navigation("Grades"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.FaceSubmission", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Group", b => + { + b.Navigation("Students"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.ScheduleSlot", b => + { + b.Navigation("Cancellations"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.Student", b => + { + b.Navigation("Memberships"); + + b.Navigation("Samples"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BiStudent.Server/Data/Migrations/20261020175237_FirebaseSync.cs b/BiStudent.Server/Data/Migrations/20261020175237_FirebaseSync.cs new file mode 100644 index 0000000..629f03b --- /dev/null +++ b/BiStudent.Server/Data/Migrations/20261020175237_FirebaseSync.cs @@ -0,0 +1,130 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BiStudent.Server.Data.Migrations +{ + /// + public partial class FirebaseSync : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ClaimToken", + table: "Teachers", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "FirebaseUid", + table: "Teachers", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "SyncAliases", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Table = table.Column(type: "TEXT", nullable: false), + CloudId = table.Column(type: "TEXT", nullable: false), + CanonicalCloudId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SyncAliases", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SyncLinks", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Table = table.Column(type: "TEXT", nullable: false), + LocalId = table.Column(type: "INTEGER", nullable: false), + CloudId = table.Column(type: "TEXT", nullable: false), + CloudUpdatedAt = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SyncLinks", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SyncOutbox", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Table = table.Column(type: "TEXT", nullable: false), + LocalId = table.Column(type: "INTEGER", nullable: false), + CloudId = table.Column(type: "TEXT", nullable: true), + Deleted = table.Column(type: "INTEGER", nullable: false), + QueuedAt = table.Column(type: "TEXT", nullable: false), + Error = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SyncOutbox", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Teachers_FirebaseUid", + table: "Teachers", + column: "FirebaseUid", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SyncAliases_Table_CloudId", + table: "SyncAliases", + columns: new[] { "Table", "CloudId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SyncLinks_Table_CloudId", + table: "SyncLinks", + columns: new[] { "Table", "CloudId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SyncLinks_Table_LocalId", + table: "SyncLinks", + columns: new[] { "Table", "LocalId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SyncOutbox_Table_LocalId", + table: "SyncOutbox", + columns: new[] { "Table", "LocalId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SyncAliases"); + + migrationBuilder.DropTable( + name: "SyncLinks"); + + migrationBuilder.DropTable( + name: "SyncOutbox"); + + migrationBuilder.DropIndex( + name: "IX_Teachers_FirebaseUid", + table: "Teachers"); + + migrationBuilder.DropColumn( + name: "ClaimToken", + table: "Teachers"); + + migrationBuilder.DropColumn( + name: "FirebaseUid", + table: "Teachers"); + } + } +} diff --git a/BiStudent.Server/Data/Migrations/AppDbContextModelSnapshot.cs b/BiStudent.Server/Data/Migrations/AppDbContextModelSnapshot.cs index f246409..8ef50f8 100644 --- a/BiStudent.Server/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/BiStudent.Server/Data/Migrations/AppDbContextModelSnapshot.cs @@ -481,15 +481,110 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Students"); }); + modelBuilder.Entity("BiStudent.Server.Data.SyncAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanonicalCloudId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CloudId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Table") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Table", "CloudId") + .IsUnique(); + + b.ToTable("SyncAliases"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.SyncLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CloudId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CloudUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("LocalId") + .HasColumnType("INTEGER"); + + b.Property("Table") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Table", "CloudId") + .IsUnique(); + + b.HasIndex("Table", "LocalId") + .IsUnique(); + + b.ToTable("SyncLinks"); + }); + + modelBuilder.Entity("BiStudent.Server.Data.SyncOutboxEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CloudId") + .HasColumnType("TEXT"); + + b.Property("Deleted") + .HasColumnType("INTEGER"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("LocalId") + .HasColumnType("INTEGER"); + + b.Property("QueuedAt") + .HasColumnType("TEXT"); + + b.Property("Table") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Table", "LocalId"); + + b.ToTable("SyncOutbox", (string)null); + }); + modelBuilder.Entity("BiStudent.Server.Data.Teacher", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("ClaimToken") + .HasColumnType("TEXT"); + b.Property("CreatedAt") .HasColumnType("TEXT"); + b.Property("FirebaseUid") + .HasColumnType("TEXT"); + b.Property("FullName") .IsRequired() .HasColumnType("TEXT"); @@ -520,6 +615,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("FirebaseUid") + .IsUnique(); + b.HasIndex("Login") .IsUnique(); diff --git a/BiStudent.Server/Data/SyncEntities.cs b/BiStudent.Server/Data/SyncEntities.cs new file mode 100644 index 0000000..340d673 --- /dev/null +++ b/BiStudent.Server/Data/SyncEntities.cs @@ -0,0 +1,36 @@ +namespace BiStudent.Server.Data; + +/// Соответствие локальной строки и документа Firestore. +public sealed class SyncLink +{ + public int Id { get; set; } + // Имя таблицы EF, оно же имя коллекции. + public string Table { get; set; } = ""; + public int LocalId { get; set; } + public string CloudId { get; set; } = ""; + // _updatedAt последней применённой или отправленной версии. + public DateTimeOffset? CloudUpdatedAt { get; set; } +} + +/// Локальное изменение, которое ещё не отправлено в Firestore. +public sealed class SyncOutboxEntry +{ + public int Id { get; set; } + public string Table { get; set; } = ""; + public int LocalId { get; set; } + // Заполняется для удалений: строки уже нет, остаётся только id документа. + public string? CloudId { get; set; } + public bool Deleted { get; set; } + public DateTime QueuedAt { get; set; } = DateTime.Now; + // Почему запись не удаётся отправить (слишком большой документ, нет прав); такие записи ждут ручного разбора. + public string? Error { get; set; } +} + +/// Списанный при слиянии дублей CloudId: ссылки на него из облака ведут на каноническую строку. +public sealed class SyncAlias +{ + public int Id { get; set; } + public string Table { get; set; } = ""; + public string CloudId { get; set; } = ""; + public string CanonicalCloudId { get; set; } = ""; +} diff --git a/BiStudent.Server/Endpoints/AuthEndpoints.cs b/BiStudent.Server/Endpoints/AuthEndpoints.cs index 4336279..0be54fc 100644 --- a/BiStudent.Server/Endpoints/AuthEndpoints.cs +++ b/BiStudent.Server/Endpoints/AuthEndpoints.cs @@ -2,6 +2,7 @@ using System.Security.Claims; using BiStudent.Contracts; using BiStudent.Server.Data; +using BiStudent.Server.Firebase; using BiStudent.Server.Services; using Microsoft.AspNetCore.Authentication.BearerToken; using Microsoft.AspNetCore.Identity; @@ -32,8 +33,15 @@ public static void MapAuth(this WebApplication app) } private static async Task LoginAsync(LoginRequest request, AppDbContext db, - IPasswordHasher hasher, SessionGeneration sessions) + IPasswordHasher hasher, SessionGeneration sessions, CloudAccountService cloud, CancellationToken ct) { + if (cloud.Enabled) + { + var result = await cloud.SignInAsync(request.Login.Trim(), request.Password, ct); + return result.Teacher is { } signedIn + ? Results.SignIn(Principal(signedIn, sessions), authenticationScheme: BearerTokenDefaults.AuthenticationScheme) + : Results.Json(new ErrorDto(result.Error ?? "Не удалось войти."), statusCode: result.Status); + } var login = request.Login.Trim().ToLowerInvariant(); var teacher = await db.Teachers.SingleOrDefaultAsync(x => x.Login.ToLower() == login); var verification = teacher is null ? PasswordVerificationResult.Failed @@ -66,15 +74,18 @@ private static async Task RefreshAsync(RefreshRequest request, AppDbCon } private static async Task ChangePasswordAsync(ChangePasswordRequest request, ClaimsPrincipal user, - AppDbContext db, IPasswordHasher hasher, SessionGeneration sessions) + AppDbContext db, IPasswordHasher hasher, SessionGeneration sessions, CloudAccountService cloud, CancellationToken ct) { var teacher = await db.Teachers.SingleAsync(x => x.Id == ApiEndpoints.TeacherId(user)); - if (hasher.VerifyHashedPassword(teacher, teacher.PasswordHash, request.CurrentPassword) == PasswordVerificationResult.Failed) + // Привязанную к Firebase учётку проверяет Firebase: локальный хеш мог устареть после смены пароля на другом компьютере. + var checkedByCloud = cloud.Enabled && teacher.FirebaseUid is not null; + if (!checkedByCloud && hasher.VerifyHashedPassword(teacher, teacher.PasswordHash, request.CurrentPassword) == PasswordVerificationResult.Failed) throw new InvalidOperationException("Текущий пароль указан неверно."); if (request.NewPassword.Length < 8) throw new InvalidOperationException("Новый пароль должен содержать не меньше 8 символов."); if (request.NewPassword == request.CurrentPassword) throw new InvalidOperationException("Новый пароль должен отличаться от текущего."); if (string.Equals(request.NewPassword, teacher.Login, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Пароль не должен совпадать с логином."); + if (cloud.Enabled) await cloud.ChangePasswordAsync(teacher, request.CurrentPassword, request.NewPassword, ct); teacher.PasswordHash = hasher.HashPassword(teacher, request.NewPassword); teacher.MustChangePassword = false; teacher.SecurityStamp = Guid.NewGuid().ToString("N"); diff --git a/BiStudent.Server/Endpoints/SyncEndpoints.cs b/BiStudent.Server/Endpoints/SyncEndpoints.cs new file mode 100644 index 0000000..93ff07e --- /dev/null +++ b/BiStudent.Server/Endpoints/SyncEndpoints.cs @@ -0,0 +1,37 @@ +using BiStudent.Contracts; +using BiStudent.Server.Data; +using BiStudent.Server.Firebase; +using BiStudent.Server.Sync; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace BiStudent.Server.Endpoints; + +public static class SyncEndpoints +{ + public static void MapSync(this WebApplication app) + { + var group = app.MapGroup("/api/sync").RequireAuthorization() + .AddEndpointFilter(ApiEndpoints.HandleErrorsAsync); + group.MapGet("/status", StatusAsync); + group.MapPost("/run", RunAsync); + } + + private static async Task StatusAsync( + AppDbContext db, SyncService sync, IOptions options, + CancellationToken cancellationToken) + { + var status = sync.Status; + return new SyncStatusDto(options.Value.Enabled, status.State.ToString(), + status.LastSuccessAt, await db.SyncOutbox.CountAsync(cancellationToken), + status.Error); + } + + private static async Task RunAsync( + AppDbContext db, SyncService sync, IOptions options, + CancellationToken cancellationToken) + { + await sync.RunAsync(cancellationToken); + return await StatusAsync(db, sync, options, cancellationToken); + } +} diff --git a/BiStudent.Server/Firebase/CloudAccountService.cs b/BiStudent.Server/Firebase/CloudAccountService.cs new file mode 100644 index 0000000..b05a1d7 --- /dev/null +++ b/BiStudent.Server/Firebase/CloudAccountService.cs @@ -0,0 +1,418 @@ +using BiStudent.Server.Data; +using BiStudent.Server.Sync; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace BiStudent.Server.Firebase; + +public sealed record CloudLoginResult(Teacher? Teacher, int Status, string? Error, bool Online); + +public sealed class CloudAccountService( + AppDbContext db, IPasswordHasher hasher, FirebaseAuthClient auth, + FirestoreClient firestore, FirebaseSessionStore sessions, SyncService sync, + SyncSignal signal, IOptions options, ILogger logger) +{ + private static readonly SemaphoreSlim loginGate = new(1, 1); + private readonly FirebaseOptions settings = options.Value; + + public bool Enabled => settings.Enabled; + + public async Task SignInAsync( + string login, string password, CancellationToken cancellationToken) + { + await loginGate.WaitAsync(cancellationToken); + try { return await SignInCoreAsync(login, password, cancellationToken); } + finally { loginGate.Release(); } + } + + private async Task SignInCoreAsync( + string login, string password, CancellationToken cancellationToken) + { + var lower = login.Trim().ToLowerInvariant(); + var local = await db.Teachers.SingleOrDefaultAsync( + teacher => teacher.Login.ToLower() == lower, cancellationToken); + CloudLoginResult result; + if (!Enabled) + result = LocalResult(local, password); + else + { + try + { + var session = await auth.SignInAsync(settings.EmailFor(login), password, + cancellationToken); + result = await LinkAndAcceptAsync(session, local, password, cancellationToken); + } + catch (FirebaseAuthException exception) when (exception.IsBadCredentials) + { + if (local is not { FirebaseUid: null } || !Matches(local, password)) + result = Failure(401, "Неверный логин или пароль."); + else + { + try + { + var session = await auth.SignUpAsync(settings.EmailFor(login), password, + cancellationToken); + result = await LinkAndAcceptAsync( + session, local, password, cancellationToken); + } + catch (FirebaseAuthException signup) when (signup.Code == "WEAK_PASSWORD") + { + result = LocalResult(local, password); + } + catch (FirebaseAuthException signup) when (signup.Code == "EMAIL_EXISTS") + { + result = Failure(401, "Неверный логин или пароль."); + } + catch (FirebaseUnavailableException) + { + result = LocalResult(local, password); + } + catch (FirebaseAuthException signup) + { + logger.LogWarning(signup, + "Перенос учётной записи {Login} отложен", login); + result = LocalResult(local, password); + } + } + } + catch (FirebaseAuthException exception) + { + result = exception.Code switch + { + "USER_DISABLED" => Failure(403, + "Учётная запись отключена в Firebase."), + "TOO_MANY_ATTEMPTS_TRY_LATER" => Failure(429, + "Слишком много попыток входа. Подождите минуту."), + "OPERATION_NOT_ALLOWED" or "CONFIGURATION_NOT_FOUND" => Failure(503, + "В проекте Firebase не включён вход по e-mail и паролю."), + _ => Failure(401, "Неверный логин или пароль.") + }; + } + catch (FirebaseUnavailableException) + { + result = LocalResult(local, password); + } + } + + if (result.Teacher is null) return result; + var teacher = result.Teacher; + if (!teacher.IsActive) + return Failure(403, "Учётная запись отключена. Обратитесь к администратору."); + var verification = Verify(teacher, password); + if (verification is PasswordVerificationResult.Failed or + PasswordVerificationResult.SuccessRehashNeeded) + teacher.PasswordHash = hasher.HashPassword(teacher, password); + teacher.LastLoginAt = DateTime.Now; + await db.SaveChangesAsync(cancellationToken); + return result; + } + + public async Task ChangePasswordAsync( + Teacher teacher, string currentPassword, string newPassword, + CancellationToken cancellationToken) + { + if (!Enabled) return; + if (teacher.FirebaseUid is not null) + { + try + { + FirebaseSession session; + try + { + session = await auth.SignInAsync( + settings.EmailFor(teacher.Login), currentPassword, cancellationToken); + } + catch (FirebaseAuthException exception) when (exception.IsBadCredentials) + { + throw new InvalidOperationException("Текущий пароль указан неверно.", exception); + } + var changed = await auth.ChangePasswordAsync( + session.IdToken, newPassword, cancellationToken); + sessions.Set(changed, teacher.Id); + } + catch (FirebaseUnavailableException exception) + { + throw new InvalidOperationException( + "Сменить пароль можно только при подключении к интернету.", exception); + } + return; + } + + if (!Matches(teacher, currentPassword)) + throw new InvalidOperationException("Текущий пароль указан неверно."); + try + { + var session = await auth.SignUpAsync( + settings.EmailFor(teacher.Login), newPassword, cancellationToken); + var result = await LinkAndAcceptAsync( + session, teacher, currentPassword, cancellationToken); + if (!result.Online) + logger.LogWarning("Перенос учётной записи {Login} отложен: {Reason}", + teacher.Login, result.Error ?? "нет прав на привязку"); + } + catch (FirebaseUnavailableException) + { + logger.LogWarning("Перенос учётной записи {Login} отложен: нет связи с Firebase", + teacher.Login); + } + catch (FirebaseAuthException exception) when (exception.Code == "EMAIL_EXISTS") + { + logger.LogWarning(exception, + "Перенос учётной записи {Login} отложен: e-mail уже занят", teacher.Login); + } + } + + public async Task CreateAccountAsync( + Teacher teacher, string password, CancellationToken cancellationToken) => + await CreateAccountCoreAsync(teacher, password, false, cancellationToken); + + public async Task LinkWithPasswordAsync( + Teacher teacher, string password, CancellationToken cancellationToken) + { + await CreateAccountCoreAsync(teacher, password, true, cancellationToken); + } + + private async Task CreateAccountCoreAsync( + Teacher teacher, string password, bool existing, CancellationToken cancellationToken) + { + if (existing && teacher.FirebaseUid is not null) + throw new InvalidOperationException("Учётная запись уже связана с Firebase."); + FirebaseSession? admin; + try { admin = await sessions.GetAsync(cancellationToken); } + catch (FirebaseUnavailableException exception) + { + throw new InvalidOperationException( + "Нет связи с Firebase — учётную запись создать нельзя.", exception); + } + if (admin is null) + throw new InvalidOperationException( + "Чтобы создать учётную запись, войдите как администратор при подключении к интернету."); + + FirebaseSession created; + try + { + created = await auth.SignUpAsync( + settings.EmailFor(teacher.Login), password, cancellationToken); + } + catch (FirebaseAuthException exception) when (exception.Code == "EMAIL_EXISTS") + { + throw new InvalidOperationException( + existing + ? "Учётная запись с таким логином уже есть в Firebase — сбросить пароль может владелец проекта в консоли Firebase." + : "Учётная запись с таким логином уже есть в Firebase.", exception); + } + catch (FirebaseUnavailableException exception) + { + throw new InvalidOperationException( + "Нет связи с Firebase — учётную запись создать нельзя.", exception); + } + + var previousUid = teacher.FirebaseUid; + try + { + teacher.FirebaseUid = created.Uid; + await SaveRemoteAsync(cancellationToken); + var cloudId = await sync.EnsureLinkAsync(db, "Teachers", teacher.Id); + await firestore.CommitAsync(admin.IdToken, + [ + await sync.BuildWriteAsync(db, "Teachers", teacher.Id), + new FirestoreWrite("TeacherUids", created.Uid, + new Dictionary { ["teacher"] = cloudId }, + MustExist: false, ServerTimestamp: false) + ], cancellationToken); + signal.Notify(); + return created.Uid; + } + catch + { + try + { + teacher.FirebaseUid = previousUid; + await SaveRemoteAsync(CancellationToken.None); + } + catch (Exception exception) + { + logger.LogWarning(exception, + "Не удалось откатить привязку преподавателя {Id}", teacher.Id); + } + try { await auth.DeleteAccountAsync(created.IdToken, CancellationToken.None); } + catch (Exception exception) + { + logger.LogWarning(exception, + "Не удалось удалить учётную запись Firebase {Uid}", created.Uid); + } + throw; + } + } + + public string? ResetPasswordBlockedReason(Teacher teacher) => + Enabled && teacher.FirebaseUid is not null + ? "Пароль учётной записи Firebase может сменить только сам преподаватель (или владелец проекта в консоли Firebase → Authentication)." + : null; + + public string? LoginChangeBlockedReason(Teacher teacher, string newLogin) => + Enabled && teacher.FirebaseUid is not null && + !string.Equals(teacher.Login, newLogin, StringComparison.OrdinalIgnoreCase) + ? "Логин связан с учётной записью Firebase и не меняется." + : null; + + private async Task LinkAndAcceptAsync( + FirebaseSession session, Teacher? local, string password, + CancellationToken cancellationToken) + { + try + { + var mapping = await firestore.GetAsync( + session.IdToken, "TeacherUids", session.Uid, cancellationToken); + if (mapping is not null) + { + try + { + await sync.PullAsync(["Teachers"], cancellationToken, session); + db.ChangeTracker.Clear(); + var teacher = await db.Teachers.SingleOrDefaultAsync( + candidate => candidate.FirebaseUid == session.Uid, cancellationToken); + var linked = teacher is not null && await db.SyncLinks.AnyAsync( + candidate => candidate.Table == "Teachers" && + candidate.LocalId == teacher.Id && + candidate.CloudId == mapping.IdFromTeacher(), cancellationToken); + if (!linked) + { + return Failure(403, + "Учётная запись не найдена в облаке BiStudent. Обратитесь к администратору."); + } + AcceptSession(session, teacher!.Id); + return new CloudLoginResult(teacher, 200, null, true); + } + catch (FirestorePermissionException exception) + { + logger.LogWarning(exception, + "Доступ к привязке Firebase {Uid} запрещён", session.Uid); + return Failure(403, + "Учётная запись отключена. Обратитесь к администратору."); + } + } + + if (local is { IsAdmin: true, FirebaseUid: null } && + Matches(local, password) && + await firestore.GetAsync( + session.IdToken, "meta", "bootstrap", cancellationToken) is null) + { + local.FirebaseUid = session.Uid; + await SaveRemoteAsync(cancellationToken); + try + { + var cloudId = await sync.EnsureLinkAsync(db, "Teachers", local.Id); + await firestore.CommitAsync(session.IdToken, + [ + new FirestoreWrite("meta", "bootstrap", + new Dictionary { ["uid"] = session.Uid }, + MustExist: false, ServerTimestamp: false), + await sync.BuildWriteAsync(db, "Teachers", local.Id), + new FirestoreWrite("TeacherUids", session.Uid, + new Dictionary { ["teacher"] = cloudId }, + MustExist: false, ServerTimestamp: false) + ], cancellationToken); + } + catch + { + local.FirebaseUid = null; + await SaveRemoteAsync(CancellationToken.None); + throw; + } + AcceptSession(session, local.Id); + signal.Notify(); + return new CloudLoginResult(local, 200, null, true); + } + + if (local is { FirebaseUid: null, ClaimToken: not null } && + Matches(local, password)) + { + var link = await db.SyncLinks.FirstOrDefaultAsync(candidate => + candidate.Table == "Teachers" && candidate.LocalId == local.Id, + cancellationToken); + if (link is not null) + { + await firestore.CommitAsync(session.IdToken, + [ + new FirestoreWrite("Teachers", link.CloudId, + new Dictionary + { + ["FirebaseUid"] = session.Uid, + ["ClaimProof"] = local.ClaimToken + }, ["FirebaseUid", "ClaimProof"]), + new FirestoreWrite("TeacherUids", session.Uid, + new Dictionary { ["teacher"] = link.CloudId }, + MustExist: false, ServerTimestamp: false) + ], cancellationToken); + local.FirebaseUid = session.Uid; + await SaveRemoteAsync(cancellationToken); + AcceptSession(session, local.Id); + return new CloudLoginResult(local, 200, null, true); + } + } + + return Unlinked(local, password, "нет подходящего преподавателя"); + } + catch (FirestorePermissionException exception) + { + return Unlinked(local, password, exception.Message); + } + catch (FirebaseUnavailableException) + { + return Matches(local, password) + ? new CloudLoginResult(local, 200, null, false) + : Failure(503, "Нет связи с Firebase, а на этом компьютере вы ещё не входили."); + } + } + + private void AcceptSession(FirebaseSession session, int teacherId) + { + var previousUid = sessions.Uid; + sessions.Set(session, teacherId); + if (previousUid is not null && previousUid != session.Uid) + sync.Reset(); + } + + private CloudLoginResult Unlinked(Teacher? local, string password, string reason) + { + if (!Matches(local, password)) + return Failure(403, + "Учётная запись Firebase не привязана к преподавателю BiStudent. Обратитесь к администратору."); + logger.LogWarning("Вход {Login} оставлен локальным: {Reason}", local!.Login, reason); + return new CloudLoginResult(local, 200, null, false); + } + + private CloudLoginResult LocalResult(Teacher? local, string password) => + Matches(local, password) + ? new CloudLoginResult(local, 200, null, false) + : local is null || string.IsNullOrEmpty(local.PasswordHash) + ? Failure(503, "Нет связи с Firebase, а на этом компьютере вы ещё не входили.") + : Failure(401, "Неверный логин или пароль."); + + private PasswordVerificationResult Verify(Teacher teacher, string password) => + string.IsNullOrEmpty(teacher.PasswordHash) ? PasswordVerificationResult.Failed + : hasher.VerifyHashedPassword(teacher, teacher.PasswordHash, password); + + private bool Matches(Teacher? teacher, string password) => + teacher is not null && Verify(teacher, password) != PasswordVerificationResult.Failed; + + private async Task SaveRemoteAsync(CancellationToken cancellationToken) + { + var applyingRemote = db.ApplyingRemote; + db.ApplyingRemote = true; + try { await db.SaveChangesAsync(cancellationToken); } + finally { db.ApplyingRemote = applyingRemote; } + } + + private static CloudLoginResult Failure(int status, string error) => + new(null, status, error, false); +} + +internal static class TeacherMapping +{ + public static string? IdFromTeacher(this FirestoreDocument mapping) => + mapping.Fields.TryGetValue("teacher", out var value) && + value.TryGetProperty("stringValue", out var id) ? id.GetString() : null; +} diff --git a/BiStudent.Server/Firebase/FirebaseAuthClient.cs b/BiStudent.Server/Firebase/FirebaseAuthClient.cs new file mode 100644 index 0000000..1ff5a8b --- /dev/null +++ b/BiStudent.Server/Firebase/FirebaseAuthClient.cs @@ -0,0 +1,161 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.Options; + +namespace BiStudent.Server.Firebase; + +public sealed record FirebaseSession( + string Uid, string Email, string IdToken, string RefreshToken, DateTimeOffset ExpiresAt) +{ + public bool ExpiresSoon(TimeProvider clock) => + ExpiresAt - clock.GetUtcNow() < TimeSpan.FromMinutes(5); +} + +public sealed class FirebaseAuthClient( + HttpClient http, IOptions options, TimeProvider? clock = null) +{ + private readonly FirebaseOptions settings = options.Value; + private readonly TimeProvider time = clock ?? TimeProvider.System; + + public Task SignInAsync( + string email, string password, CancellationToken cancellationToken = default) => + AccountAsync("signInWithPassword", new { email, password, returnSecureToken = true }, + cancellationToken); + + public Task SignUpAsync( + string email, string password, CancellationToken cancellationToken = default) => + AccountAsync("signUp", new { email, password, returnSecureToken = true }, + cancellationToken); + + public async Task RefreshAsync( + string refreshToken, string email, CancellationToken cancellationToken = default) + { + var url = string.IsNullOrWhiteSpace(settings.AuthEmulatorHost) + ? "https://securetoken.googleapis.com/v1/token" + : $"http://{settings.AuthEmulatorHost}/securetoken.googleapis.com/v1/token"; + using var request = new HttpRequestMessage(HttpMethod.Post, WithKey(url)) + { + Content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "refresh_token", + ["refresh_token"] = refreshToken + }) + }; + using var response = await SendAsync(request, cancellationToken); + using var document = await JsonDocument.ParseAsync( + await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); + var root = document.RootElement; + return new FirebaseSession( + root.GetProperty("user_id").GetString()!, + email, + root.GetProperty("id_token").GetString()!, + root.GetProperty("refresh_token").GetString()!, + Expiry(root.GetProperty("expires_in"))); + } + + public Task ChangePasswordAsync( + string idToken, string newPassword, CancellationToken cancellationToken = default) => + AccountAsync("update", + new { idToken, password = newPassword, returnSecureToken = true }, cancellationToken); + + public async Task SendPasswordResetAsync( + string email, CancellationToken cancellationToken = default) + { + using var request = AccountRequest("sendOobCode", + new { email, requestType = "PASSWORD_RESET" }); + using var response = await SendAsync(request, cancellationToken); + } + + public async Task DeleteAccountAsync( + string idToken, CancellationToken cancellationToken = default) + { + using var request = AccountRequest("delete", new { idToken }); + using var response = await SendAsync(request, cancellationToken); + } + + private async Task AccountAsync( + string method, object body, CancellationToken cancellationToken) + { + using var request = AccountRequest(method, body); + using var response = await SendAsync(request, cancellationToken); + using var document = await JsonDocument.ParseAsync( + await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); + var root = document.RootElement; + return new FirebaseSession( + root.GetProperty("localId").GetString()!, + root.GetProperty("email").GetString()!, + root.GetProperty("idToken").GetString()!, + root.GetProperty("refreshToken").GetString()!, + Expiry(root.GetProperty("expiresIn"))); + } + + private HttpRequestMessage AccountRequest(string method, object body) + { + var url = string.IsNullOrWhiteSpace(settings.AuthEmulatorHost) + ? $"https://identitytoolkit.googleapis.com/v1/accounts:{method}" + : $"http://{settings.AuthEmulatorHost}/identitytoolkit.googleapis.com/v1/accounts:{method}"; + return new HttpRequestMessage(HttpMethod.Post, WithKey(url)) + { + Content = JsonContent.Create(body) + }; + } + + private string WithKey(string url) => + url + "?key=" + Uri.EscapeDataString(settings.ApiKey); + + private DateTimeOffset Expiry(JsonElement value) + { + var seconds = value.ValueKind == JsonValueKind.String + ? long.Parse(value.GetString()!, System.Globalization.CultureInfo.InvariantCulture) + : value.GetInt64(); + return time.GetUtcNow().AddSeconds(seconds); + } + + private async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(settings.RequestTimeoutSeconds)); + HttpResponseMessage response; + try + { + response = await http.SendAsync(request, timeout.Token); + } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new FirebaseUnavailableException("Firebase временно недоступен.", exception); + } + catch (HttpRequestException exception) + { + throw new FirebaseUnavailableException("Firebase временно недоступен.", exception); + } + + if (response.IsSuccessStatusCode) return response; + using (response) + { + if (response.StatusCode == HttpStatusCode.TooManyRequests || + (int)response.StatusCode >= 500) + throw new FirebaseUnavailableException("Firebase временно недоступен."); + + string code; + try + { + using var document = JsonDocument.Parse( + await response.Content.ReadAsStringAsync(cancellationToken)); + var message = document.RootElement.GetProperty("error") + .GetProperty("message").GetString() ?? ""; + code = message.Split([' ', ':'], 2)[0]; + } + catch (JsonException) + { + code = "UNKNOWN_ERROR"; + } + catch (KeyNotFoundException) + { + code = "UNKNOWN_ERROR"; + } + throw new FirebaseAuthException(code); + } + } +} diff --git a/BiStudent.Server/Firebase/FirebaseErrors.cs b/BiStudent.Server/Firebase/FirebaseErrors.cs new file mode 100644 index 0000000..c7f6204 --- /dev/null +++ b/BiStudent.Server/Firebase/FirebaseErrors.cs @@ -0,0 +1,52 @@ +namespace BiStudent.Server.Firebase; + +public sealed class FirebaseUnavailableException : Exception +{ + public FirebaseUnavailableException(string message, Exception? innerException = null) + : base(message, innerException) { } +} + +public sealed class FirebaseAuthException : Exception +{ + public string Code { get; } + + public bool IsBadCredentials => Code is "INVALID_LOGIN_CREDENTIALS" or + "INVALID_PASSWORD" or "EMAIL_NOT_FOUND"; + + public bool IsUnknownAccount => Code == "EMAIL_NOT_FOUND"; + + public FirebaseAuthException(string code) : base(Describe(code)) + { + Code = code; + } + + private static string Describe(string code) => code switch + { + "INVALID_LOGIN_CREDENTIALS" or "INVALID_PASSWORD" or "EMAIL_NOT_FOUND" => + "Неверный логин или пароль.", + "EMAIL_EXISTS" => "Такая учётная запись уже существует.", + "WEAK_PASSWORD" => "Пароль слишком слабый.", + "USER_DISABLED" => "Учётная запись отключена.", + "TOO_MANY_ATTEMPTS_TRY_LATER" => "Слишком много попыток. Повторите позже.", + "OPERATION_NOT_ALLOWED" => "Вход по паролю не включён.", + "CONFIGURATION_NOT_FOUND" => "Firebase не настроен.", + "TOKEN_EXPIRED" or "INVALID_REFRESH_TOKEN" => "Срок действия входа истёк.", + "USER_NOT_FOUND" => "Учётная запись не найдена.", + _ => "Ошибка Firebase Authentication: " + code + }; +} + +public sealed class FirestorePermissionException : Exception +{ + public FirestorePermissionException(string message) : base(message) { } +} + +public sealed class FirestoreRequestException : Exception +{ + public string Status { get; } + + public FirestoreRequestException(string status, string message) : base(message) + { + Status = status; + } +} diff --git a/BiStudent.Server/Firebase/FirebaseOptions.cs b/BiStudent.Server/Firebase/FirebaseOptions.cs new file mode 100644 index 0000000..b0f938b --- /dev/null +++ b/BiStudent.Server/Firebase/FirebaseOptions.cs @@ -0,0 +1,17 @@ +namespace BiStudent.Server.Firebase; + +public sealed class FirebaseOptions +{ + public string ApiKey { get; set; } = ""; + public string ProjectId { get; set; } = ""; + public string? AuthEmulatorHost { get; set; } + public string? FirestoreEmulatorHost { get; set; } + public string EmailDomain { get; set; } = "bistudent.app"; + public int SyncIntervalSeconds { get; set; } = 60; + public int RequestTimeoutSeconds { get; set; } = 15; + + public bool Enabled => !string.IsNullOrWhiteSpace(ApiKey) && + !string.IsNullOrWhiteSpace(ProjectId); + + public string EmailFor(string login) => login.Trim().ToLowerInvariant() + "@" + EmailDomain; +} diff --git a/BiStudent.Server/Firebase/FirebaseSessionStore.cs b/BiStudent.Server/Firebase/FirebaseSessionStore.cs new file mode 100644 index 0000000..0358f9c --- /dev/null +++ b/BiStudent.Server/Firebase/FirebaseSessionStore.cs @@ -0,0 +1,131 @@ +using System.Security.Cryptography; +using System.Text.Json; +using BiStudent.Server.Services; +using Microsoft.AspNetCore.DataProtection; + +namespace BiStudent.Server.Firebase; + +public sealed class FirebaseSessionStore( + FirebaseAuthClient auth, IDataProtectionProvider protection, StoragePaths paths, + TimeProvider? clock = null) +{ + private sealed record StoredSession(string Uid, string Email, string RefreshToken, int TeacherId); + + private readonly SemaphoreSlim gate = new(1, 1); + private readonly IDataProtector protector = + protection.CreateProtector("BiStudent.FirebaseSession"); + private readonly TimeProvider time = clock ?? TimeProvider.System; + private FirebaseSession? session; + private int? teacherId; + + private string FilePath => Path.Combine(paths.DataDirectory, "firebase.session"); + + public int? TeacherId + { + get + { + gate.Wait(); + try { return teacherId; } + finally { gate.Release(); } + } + } + + public string? Uid + { + get + { + gate.Wait(); + try + { + if (session is not null) return session.Uid; + if (!File.Exists(FilePath)) return null; + try + { + return JsonSerializer.Deserialize( + protector.Unprotect(File.ReadAllBytes(FilePath)))?.Uid; + } + catch (Exception exception) when (exception is CryptographicException or + JsonException or IOException) { return null; } + } + finally { gate.Release(); } + } + } + + public void Set(FirebaseSession value, int localTeacherId) + { + gate.Wait(); + try { Save(value, localTeacherId); } + finally { gate.Release(); } + } + + public void Clear() + { + gate.Wait(); + try { ClearStored(); } + finally { gate.Release(); } + } + + public async Task GetAsync(CancellationToken cancellationToken = default) + { + await gate.WaitAsync(cancellationToken); + try + { + if (session is null && File.Exists(FilePath)) + { + try + { + var bytes = await File.ReadAllBytesAsync(FilePath, cancellationToken); + var stored = JsonSerializer.Deserialize(protector.Unprotect(bytes)) + ?? throw new JsonException(); + teacherId = stored.TeacherId; + session = new FirebaseSession(stored.Uid, stored.Email, "", + stored.RefreshToken, DateTimeOffset.MinValue); + } + catch (Exception exception) when (exception is CryptographicException or + JsonException or FormatException) + { + ClearStored(); + return null; + } + } + + if (session is null || !session.ExpiresSoon(time)) return session; + try + { + var refreshed = await auth.RefreshAsync( + session.RefreshToken, session.Email, cancellationToken); + Save(refreshed, teacherId!.Value); + return refreshed; + } + catch (FirebaseAuthException exception) when (exception.Code is + "TOKEN_EXPIRED" or "INVALID_REFRESH_TOKEN" or "USER_DISABLED" or "USER_NOT_FOUND") + { + ClearStored(); + return null; + } + } + finally + { + gate.Release(); + } + } + + private void Save(FirebaseSession value, int localTeacherId) + { + var stored = new StoredSession(value.Uid, value.Email, value.RefreshToken, localTeacherId); + var bytes = protector.Protect(JsonSerializer.SerializeToUtf8Bytes(stored)); + Directory.CreateDirectory(paths.DataDirectory); + var temporary = FilePath + ".tmp"; + File.WriteAllBytes(temporary, bytes); + File.Move(temporary, FilePath, overwrite: true); + session = value; + teacherId = localTeacherId; + } + + private void ClearStored() + { + session = null; + teacherId = null; + if (File.Exists(FilePath)) File.Delete(FilePath); + } +} diff --git a/BiStudent.Server/Firebase/FirestoreClient.cs b/BiStudent.Server/Firebase/FirestoreClient.cs new file mode 100644 index 0000000..74f4a19 --- /dev/null +++ b/BiStudent.Server/Firebase/FirestoreClient.cs @@ -0,0 +1,231 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.Options; + +namespace BiStudent.Server.Firebase; + +public sealed record FirestoreDocument( + string Collection, string Id, IReadOnlyDictionary Fields, + DateTimeOffset? UpdatedAt); + +public sealed record FirestoreWrite( + string Collection, string Id, Dictionary? Fields, + string[]? UpdateMask = null, bool? MustExist = null, + bool ServerTimestamp = true, bool IsDelete = false) +{ + public static FirestoreWrite Delete(string collection, string id) => + new(collection, id, null, ServerTimestamp: false, IsDelete: true); +} + +public sealed record FirestoreWriteResult(DateTimeOffset? UpdateTime); + +public sealed class FirestoreClient(HttpClient http, IOptions options) +{ + private readonly FirebaseOptions settings = options.Value; + + private string Root + { + get + { + var host = string.IsNullOrWhiteSpace(settings.FirestoreEmulatorHost) + ? "https://firestore.googleapis.com" + : $"http://{settings.FirestoreEmulatorHost}"; + return $"{host}/v1/projects/{Uri.EscapeDataString(settings.ProjectId)}/databases/(default)/documents"; + } + } + + public async Task GetAsync( + string idToken, string collection, string id, CancellationToken cancellationToken = default) + { + using var request = Request(HttpMethod.Get, + Root + "/" + Uri.EscapeDataString(collection) + "/" + Uri.EscapeDataString(id), idToken); + using var response = await SendAsync(request, cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) return null; + using var document = await ReadAsync(response, cancellationToken); + return ParseDocument(document.RootElement, collection); + } + + public async Task> QueryChangedAsync( + string idToken, string collection, DateTimeOffset? after, int limit, + CancellationToken cancellationToken = default, FirestoreDocument? afterDocument = null) + { + var query = new Dictionary + { + ["from"] = new[] { new { collectionId = collection } }, + ["orderBy"] = new[] + { + new { field = new { fieldPath = "_updatedAt" }, direction = "ASCENDING" }, + new { field = new { fieldPath = "__name__" }, direction = "ASCENDING" } + }, + ["limit"] = limit + }; + if (after is not null) + query["where"] = new + { + fieldFilter = new + { + field = new { fieldPath = "_updatedAt" }, + op = "GREATER_THAN", + value = new { timestampValue = after.Value.ToString("O") } + } + }; + if (afterDocument is not null) + { + if (afterDocument.UpdatedAt is null || afterDocument.Collection != collection) + throw new ArgumentException("Неверная позиция страницы.", nameof(afterDocument)); + query["startAt"] = new + { + values = new object[] + { + new { timestampValue = afterDocument.UpdatedAt.Value.ToString("O") }, + new + { + referenceValue = $"projects/{settings.ProjectId}/databases/(default)/documents/" + + collection + "/" + afterDocument.Id + } + }, + before = false + }; + } + using var request = Request(HttpMethod.Post, Root + ":runQuery", idToken); + request.Content = JsonContent.Create(new { structuredQuery = query }); + using var response = await SendAsync(request, cancellationToken); + using var document = await ReadAsync(response, cancellationToken); + var result = new List(); + foreach (var item in document.RootElement.EnumerateArray()) + if (item.TryGetProperty("document", out var entry)) + result.Add(ParseDocument(entry, collection)); + return result; + } + + public async Task> CommitAsync( + string idToken, IReadOnlyList writes, + CancellationToken cancellationToken = default) + { + var payload = new List>(writes.Count); + foreach (var write in writes) + { + var name = $"projects/{settings.ProjectId}/databases/(default)/documents/" + + write.Collection + "/" + write.Id; + var item = new Dictionary(); + if (write.IsDelete) + { + item["delete"] = name; + } + else + { + item["update"] = new + { + name, + fields = write.Fields!.ToDictionary( + field => field.Key, field => FirestoreValues.ToValue(field.Value)) + }; + if (write.UpdateMask is not null) + item["updateMask"] = new { fieldPaths = write.UpdateMask }; + if (write.ServerTimestamp) + item["updateTransforms"] = new[] + { + new { fieldPath = "_updatedAt", setToServerValue = "REQUEST_TIME" } + }; + } + if (write.MustExist is not null) + item["currentDocument"] = new { exists = write.MustExist.Value }; + payload.Add(item); + } + + using var request = Request(HttpMethod.Post, Root + ":commit", idToken); + request.Content = JsonContent.Create(new { writes = payload }); + using var response = await SendAsync(request, cancellationToken); + using var document = await ReadAsync(response, cancellationToken); + var results = new List(); + if (document.RootElement.TryGetProperty("writeResults", out var entries)) + foreach (var entry in entries.EnumerateArray()) + results.Add(new FirestoreWriteResult( + entry.TryGetProperty("updateTime", out var value) + ? DateTimeOffset.Parse(value.GetString()!, + System.Globalization.CultureInfo.InvariantCulture) + : null)); + return results; + } + + private static FirestoreDocument ParseDocument(JsonElement element, string collection) + { + var name = element.GetProperty("name").GetString()!; + var fields = new Dictionary(); + if (element.TryGetProperty("fields", out var values)) + foreach (var field in values.EnumerateObject()) + fields[field.Name] = field.Value.Clone(); + DateTimeOffset? updated = null; + if (fields.TryGetValue("_updatedAt", out var timestamp) && + timestamp.TryGetProperty("timestampValue", out var value)) + updated = DateTimeOffset.Parse(value.GetString()!, + System.Globalization.CultureInfo.InvariantCulture); + return new FirestoreDocument(collection, name[(name.LastIndexOf('/') + 1)..], + fields, updated); + } + + private static HttpRequestMessage Request(HttpMethod method, string url, string token) + { + var request = new HttpRequestMessage(method, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + return request; + } + + private async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(settings.RequestTimeoutSeconds)); + HttpResponseMessage response; + try + { + response = await http.SendAsync(request, timeout.Token); + } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new FirebaseUnavailableException("Firestore временно недоступен.", exception); + } + catch (HttpRequestException exception) + { + throw new FirebaseUnavailableException("Firestore временно недоступен.", exception); + } + + if (response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.NotFound && + request.Method == HttpMethod.Get) return response; + + using (response) + { + if (response.StatusCode == HttpStatusCode.TooManyRequests || + (int)response.StatusCode >= 500) + throw new FirebaseUnavailableException("Firestore временно недоступен."); + + var status = response.StatusCode.ToString(); + var message = "Ошибка запроса к Firestore."; + try + { + using var error = JsonDocument.Parse( + await response.Content.ReadAsStringAsync(cancellationToken)); + var details = error.RootElement.GetProperty("error"); + if (details.TryGetProperty("status", out var code)) + status = code.GetString() ?? status; + if (details.TryGetProperty("message", out var text)) + message = text.GetString() ?? message; + } + catch (JsonException) { } + catch (KeyNotFoundException) { } + + if (response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized || + status is "PERMISSION_DENIED" or "UNAUTHENTICATED") + throw new FirestorePermissionException(message); + throw new FirestoreRequestException(status, message); + } + } + + private static async Task ReadAsync( + HttpResponseMessage response, CancellationToken cancellationToken) => + await JsonDocument.ParseAsync( + await response.Content.ReadAsStreamAsync(cancellationToken), + cancellationToken: cancellationToken); +} diff --git a/BiStudent.Server/Firebase/FirestoreValues.cs b/BiStudent.Server/Firebase/FirestoreValues.cs new file mode 100644 index 0000000..ffd147e --- /dev/null +++ b/BiStudent.Server/Firebase/FirestoreValues.cs @@ -0,0 +1,70 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace BiStudent.Server.Firebase; + +public static class FirestoreValues +{ + public static JsonObject ToValue(object? value) => value switch + { + null => new() { ["nullValue"] = "NULL_VALUE" }, + bool boolean => new() { ["booleanValue"] = boolean }, + int integer => new() { ["integerValue"] = integer.ToString(CultureInfo.InvariantCulture) }, + long integer => new() { ["integerValue"] = integer.ToString(CultureInfo.InvariantCulture) }, + double number => new() { ["doubleValue"] = number }, + string text => new() { ["stringValue"] = text }, + byte[] bytes => new() { ["bytesValue"] = Convert.ToBase64String(bytes) }, + DateTimeOffset timestamp => new() { ["timestampValue"] = timestamp.ToString("O", CultureInfo.InvariantCulture) }, + DateTime dateTime => new() { ["stringValue"] = dateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffffff", CultureInfo.InvariantCulture) }, + DateOnly date => new() { ["stringValue"] = date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) }, + TimeOnly time => new() { ["stringValue"] = time.ToString("HH:mm:ss.fffffff", CultureInfo.InvariantCulture) }, + Enum member => new() { ["stringValue"] = member.ToString() }, + _ => throw new NotSupportedException($"Тип {value.GetType()} не поддерживается Firestore.") + }; + + public static object? FromValue(JsonElement value, Type target) + { + var type = Nullable.GetUnderlyingType(target) ?? target; + if (value.TryGetProperty("nullValue", out _)) + { + if (type.IsValueType && Nullable.GetUnderlyingType(target) is null) + throw new FormatException("Null для обязательного поля."); + return null; + } + + if (type == typeof(bool)) return value.GetProperty("booleanValue").GetBoolean(); + if (type == typeof(int)) + return int.Parse(Text(value.GetProperty("integerValue")), CultureInfo.InvariantCulture); + if (type == typeof(long)) + return long.Parse(Text(value.GetProperty("integerValue")), CultureInfo.InvariantCulture); + if (type == typeof(double)) + return double.Parse(Text(value.GetProperty("doubleValue")), CultureInfo.InvariantCulture); + if (type == typeof(string)) return value.GetProperty("stringValue").GetString(); + if (type == typeof(byte[])) + return Convert.FromBase64String(value.GetProperty("bytesValue").GetString()!); + if (type == typeof(DateTimeOffset)) + return DateTimeOffset.Parse(value.GetProperty("timestampValue").GetString()!, + CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + if (type == typeof(DateTime)) + return DateTime.ParseExact(value.GetProperty("stringValue").GetString()!, + "yyyy-MM-ddTHH:mm:ss.fffffff", CultureInfo.InvariantCulture, DateTimeStyles.None); + if (type == typeof(DateOnly)) + return DateOnly.ParseExact(value.GetProperty("stringValue").GetString()!, + "yyyy-MM-dd", CultureInfo.InvariantCulture); + if (type == typeof(TimeOnly)) + return TimeOnly.ParseExact(value.GetProperty("stringValue").GetString()!, + "HH:mm:ss.fffffff", CultureInfo.InvariantCulture); + if (type.IsEnum) + { + var text = value.GetProperty("stringValue").GetString()!; + if (Enum.TryParse(type, text, false, out var member) && + Enum.IsDefined(type, member!)) return member; + throw new FormatException("Неизвестное значение перечисления: " + text); + } + throw new NotSupportedException($"Тип {target} не поддерживается Firestore."); + } + + private static string Text(JsonElement value) => + value.ValueKind == JsonValueKind.String ? value.GetString()! : value.GetRawText(); +} diff --git a/BiStudent.Server/Program.cs b/BiStudent.Server/Program.cs index 9b80f77..1cf7e1b 100644 --- a/BiStudent.Server/Program.cs +++ b/BiStudent.Server/Program.cs @@ -4,7 +4,9 @@ using System.Threading.RateLimiting; using BiStudent.Server.Data; using BiStudent.Server.Endpoints; +using BiStudent.Server.Firebase; using BiStudent.Server.Services; +using BiStudent.Server.Sync; using Microsoft.AspNetCore.Authentication.BearerToken; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; @@ -29,7 +31,23 @@ Directory.CreateDirectory(data); Directory.CreateDirectory(Path.Combine(data, "keys")); -builder.Services.AddDbContext(options => options.UseSqlite(sqlite.ToString())); +var firebase = builder.Configuration.GetSection("Firebase").Get() ?? new FirebaseOptions(); +builder.Services.Configure(builder.Configuration.GetSection("Firebase")); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddDbContext((services, options) => +{ + options.UseSqlite(sqlite.ToString()); + // Изменения попадают в очередь отправки в Firestore, только если Firebase настроен. + if (firebase.Enabled) options.AddInterceptors(services.GetRequiredService()); +}); +// Firebase: SQLite — локальный кеш, основная копия данных и учётки — в Firestore и Firebase Authentication. +builder.Services.AddHttpClient(); +builder.Services.AddHttpClient(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); builder.Services.AddHealthChecks().AddDbContextCheck(); builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(data, "keys"))) .SetApplicationName("BiStudent"); @@ -195,6 +213,10 @@ await context.HttpContext.Response.WriteAsJsonAsync( : Results.Redirect("/register/")); app.MapApi(); app.MapEnrollment(); +app.MapSync(); +var sync = app.Services.GetRequiredService(); +var recognition = app.Services.GetRequiredService(); +sync.FacesChanged += recognition.Invalidate; using (var scope = app.Services.CreateScope()) { diff --git a/BiStudent.Server/Services/AccountService.cs b/BiStudent.Server/Services/AccountService.cs index eebf00d..2f64037 100644 --- a/BiStudent.Server/Services/AccountService.cs +++ b/BiStudent.Server/Services/AccountService.cs @@ -2,12 +2,13 @@ using System.Text.RegularExpressions; using BiStudent.Contracts; using BiStudent.Server.Data; +using BiStudent.Server.Firebase; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; namespace BiStudent.Server.Services; -public sealed class AccountService(AppDbContext db, IPasswordHasher hasher) +public sealed class AccountService(AppDbContext db, IPasswordHasher hasher, CloudAccountService cloud) { private const string PasswordAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789"; private static readonly Regex LoginPattern = new(@"^[A-Za-z0-9._-]{3,32}$", RegexOptions.CultureInvariant); @@ -28,6 +29,16 @@ public async Task CreateAsync(SaveTeacherRequest request) teacher.PasswordHash = hasher.HashPassword(teacher, password); db.Teachers.Add(teacher); await db.SaveChangesAsync(); + if (cloud.Enabled) + { + try { await cloud.CreateAccountAsync(teacher, password, CancellationToken.None); } + catch + { + db.Teachers.Remove(teacher); + await db.SaveChangesAsync(); + throw; + } + } return new TemporaryPasswordDto(login, password); } finally { accountGate.Release(); } @@ -41,6 +52,7 @@ public async Task UpdateAsync(int id, SaveTeacherRequest requ var teacher = await db.Teachers.SingleOrDefaultAsync(x => x.Id == id) ?? throw new KeyNotFoundException("Преподаватель не найден."); var (login, name) = await ValidateAsync(request, id); + if (cloud.LoginChangeBlockedReason(teacher, login) is { } loginBlocked) throw new InvalidOperationException(loginBlocked); if (id == actorId && (!request.IsAdmin || !request.IsActive)) throw new InvalidOperationException("Нельзя снять с себя права администратора или отключить свою учётную запись."); if (teacher.IsAdmin && teacher.IsActive && (!request.IsAdmin || !request.IsActive) && @@ -66,7 +78,11 @@ public async Task ResetPasswordAsync(int id, int actorId) { var teacher = await db.Teachers.SingleOrDefaultAsync(x => x.Id == id) ?? throw new KeyNotFoundException("Преподаватель не найден."); + if (cloud.ResetPasswordBlockedReason(teacher) is { } resetBlocked) throw new InvalidOperationException(resetBlocked); var password = RandomNumberGenerator.GetString(PasswordAlphabet, 10); + // Непривязанная учётка получает аккаунт Firebase с этим временным паролем. + if (cloud.Enabled && teacher.FirebaseUid is null) + await cloud.LinkWithPasswordAsync(teacher, password, CancellationToken.None); teacher.PasswordHash = hasher.HashPassword(teacher, password); teacher.MustChangePassword = true; teacher.SecurityStamp = Guid.NewGuid().ToString("N"); diff --git a/BiStudent.Server/Services/EnrollmentService.cs b/BiStudent.Server/Services/EnrollmentService.cs index 523dfa1..1059946 100644 --- a/BiStudent.Server/Services/EnrollmentService.cs +++ b/BiStudent.Server/Services/EnrollmentService.cs @@ -3,6 +3,7 @@ using System.Security.Cryptography; using BiStudent.Contracts; using BiStudent.Server.Data; +using BiStudent.Server.Sync; using Microsoft.EntityFrameworkCore; namespace BiStudent.Server.Services; @@ -187,6 +188,8 @@ await recognition.RunExclusiveAsync(async () => .SetProperty(x => x.ReviewedAt, reviewedAt) .SetProperty(x => x.ReviewedByTeacherId, teacherId)); if (updated != 1) throw new InvalidOperationException("Заявка уже рассмотрена."); + // ExecuteUpdate идёт мимо ChangeTracker, поэтому в очередь Firestore ставим вручную. + SyncCaptureInterceptor.Enqueue(db, id); var images = await db.FaceSubmissionImages.Where(x => x.SubmissionId == id).OrderBy(x => x.Id).ToListAsync(); var crops = images.Select(x => recognition.UnprotectImage(x.ProtectedImage)).ToList(); var old = await db.FaceSamples.Where(x => x.StudentId == submission.StudentId).ToListAsync(); @@ -218,6 +221,8 @@ await recognition.RunExclusiveAsync(async () => .SetProperty(x => x.ReviewedAt, reviewedAt) .SetProperty(x => x.ReviewedByTeacherId, teacherId)); if (updated != 1) throw new InvalidOperationException("Заявка уже рассмотрена."); + // ExecuteUpdate идёт мимо ChangeTracker, поэтому в очередь Firestore ставим вручную. + SyncCaptureInterceptor.Enqueue(db, id); var images = await db.FaceSubmissionImages.Where(x => x.SubmissionId == id).ToListAsync(); db.FaceSubmissionImages.RemoveRange(images); await db.SaveChangesAsync(); diff --git a/BiStudent.Server/Sync/SyncCaptureInterceptor.cs b/BiStudent.Server/Sync/SyncCaptureInterceptor.cs new file mode 100644 index 0000000..115ae99 --- /dev/null +++ b/BiStudent.Server/Sync/SyncCaptureInterceptor.cs @@ -0,0 +1,366 @@ +using System.Data.Common; +using System.Globalization; +using System.Runtime.CompilerServices; +using BiStudent.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Storage; + +namespace BiStudent.Server.Sync; + +public sealed class SyncCaptureInterceptor(SyncSignal signal) : SaveChangesInterceptor +{ + private sealed record Pending( + EntityEntry? Entry, string Table, int LocalId, bool Deleted, string? CloudId); + + private sealed class State + { + public IDbContextTransaction? Transaction; + public bool ManualOutbox; + public List Entries { get; } = []; + } + + private readonly ConditionalWeakTable states = new(); + + public static void Enqueue(AppDbContext db, int id) where T : class + { + var table = db.Model.FindEntityType(typeof(T))?.GetTableName() + ?? throw new InvalidOperationException("Таблица не найдена."); + db.SyncOutbox.Add(new SyncOutboxEntry { Table = table, LocalId = id }); + } + + public override InterceptionResult SavingChanges( + DbContextEventData eventData, InterceptionResult result) + { + if (eventData.Context is AppDbContext db && !db.ApplyingRemote) + Prepare(db); + return result; + } + + public override async ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, + CancellationToken cancellationToken = default) + { + if (eventData.Context is AppDbContext db && !db.ApplyingRemote) + await PrepareAsync(db, cancellationToken); + return result; + } + + public override int SavedChanges(SaveChangesCompletedEventData eventData, int result) + { + if (eventData.Context is AppDbContext db) + Finish(db); + return result; + } + + public override async ValueTask SavedChangesAsync( + SaveChangesCompletedEventData eventData, int result, + CancellationToken cancellationToken = default) + { + if (eventData.Context is AppDbContext db) + await FinishAsync(db, cancellationToken); + return result; + } + + public override void SaveChangesFailed(DbContextErrorEventData eventData) + { + if (eventData.Context is AppDbContext db) + Fail(db); + } + + public override async Task SaveChangesFailedAsync( + DbContextErrorEventData eventData, CancellationToken cancellationToken = default) + { + if (eventData.Context is AppDbContext db) + await FailAsync(db, cancellationToken); + } + + public override void SaveChangesCanceled(DbContextEventData eventData) + { + if (eventData.Context is AppDbContext db) + Fail(db); + } + + public override async Task SaveChangesCanceledAsync( + DbContextEventData eventData, CancellationToken cancellationToken = default) + { + if (eventData.Context is AppDbContext db) + await FailAsync(db, CancellationToken.None); + } + + private State? Collect(AppDbContext db) + { + states.Remove(db); + var model = new SyncModel(db); + var names = model.Tables.ToDictionary(table => table.ClrType, table => table.Name); + var state = new State(); + foreach (var entry in db.ChangeTracker.Entries()) + { + if (entry.Entity is SyncOutboxEntry && entry.State == EntityState.Added) + state.ManualOutbox = true; + if (!names.TryGetValue(entry.Metadata.ClrType, out var table)) continue; + if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted)) + continue; + if (entry.Metadata.ClrType == typeof(Teacher) && + entry.State == EntityState.Modified && + !entry.Properties.Any(property => property.IsModified && + property.Metadata.Name is not (nameof(Teacher.PasswordHash) or + nameof(Teacher.SecurityStamp) or + nameof(Teacher.LastLoginAt) or + nameof(Teacher.ClaimToken)))) + continue; + state.Entries.Add(new Pending(entry, table, + entry.State == EntityState.Added ? 0 : + (int)entry.Property("Id").CurrentValue!, + entry.State == EntityState.Deleted, null)); + } + if (state.Entries.Count == 0 && !state.ManualOutbox) return null; + states.Add(db, state); + return state; + } + + private void Prepare(AppDbContext db) + { + var state = Collect(db); + if (state is null) return; + try + { + if (state.Entries.Count > 0 && db.Database.CurrentTransaction is null) + state.Transaction = db.Database.BeginTransaction(); + ExpandCascades(db, state); + for (var i = state.Entries.Count - 1; i >= 0; i--) + { + var entry = state.Entries[i]; + if (!entry.Deleted) continue; + var cloudId = db.SyncLinks.AsNoTracking() + .Where(link => link.Table == entry.Table && link.LocalId == entry.LocalId) + .Select(link => link.CloudId).FirstOrDefault(); + if (cloudId is null) state.Entries.RemoveAt(i); + else state.Entries[i] = entry with { CloudId = cloudId }; + } + } + catch + { + states.Remove(db); + state.Transaction?.Dispose(); + throw; + } + } + + private async Task PrepareAsync(AppDbContext db, CancellationToken cancellationToken) + { + var state = Collect(db); + if (state is null) return; + try + { + if (state.Entries.Count > 0 && db.Database.CurrentTransaction is null) + state.Transaction = await db.Database.BeginTransactionAsync(cancellationToken); + await ExpandCascadesAsync(db, state, cancellationToken); + for (var i = state.Entries.Count - 1; i >= 0; i--) + { + var entry = state.Entries[i]; + if (!entry.Deleted) continue; + var cloudId = await db.SyncLinks.AsNoTracking() + .Where(link => link.Table == entry.Table && link.LocalId == entry.LocalId) + .Select(link => link.CloudId).FirstOrDefaultAsync(cancellationToken); + if (cloudId is null) state.Entries.RemoveAt(i); + else state.Entries[i] = entry with { CloudId = cloudId }; + } + } + catch + { + states.Remove(db); + if (state.Transaction is not null) + await state.Transaction.DisposeAsync(); + throw; + } + } + + private void Finish(AppDbContext db) + { + if (!states.TryGetValue(db, out var state)) return; + try + { + var queuedAt = DateTime.Now.ToString( + "yyyy-MM-dd HH:mm:ss.FFFFFFF", CultureInfo.InvariantCulture); + foreach (var entry in state.Entries) + { + var table = entry.Table; + var localId = entry.Entry is null ? entry.LocalId : + (int)entry.Entry.Property("Id").CurrentValue!; + var cloudId = entry.CloudId; + var deleted = entry.Deleted ? 1 : 0; + db.Database.ExecuteSql( + $"INSERT INTO \"SyncOutbox\" (\"Table\", \"LocalId\", \"CloudId\", \"Deleted\", \"QueuedAt\") VALUES ({table}, {localId}, {cloudId}, {deleted}, {queuedAt})"); + } + state.Transaction?.Commit(); + if (state.Entries.Count > 0 || state.ManualOutbox) signal.Notify(); + } + catch + { + state.Transaction?.Rollback(); + throw; + } + finally + { + state.Transaction?.Dispose(); + states.Remove(db); + } + } + + private async Task FinishAsync(AppDbContext db, CancellationToken cancellationToken) + { + if (!states.TryGetValue(db, out var state)) return; + try + { + var queuedAt = DateTime.Now.ToString( + "yyyy-MM-dd HH:mm:ss.FFFFFFF", CultureInfo.InvariantCulture); + foreach (var entry in state.Entries) + { + var table = entry.Table; + var localId = entry.Entry is null ? entry.LocalId : + (int)entry.Entry.Property("Id").CurrentValue!; + var cloudId = entry.CloudId; + var deleted = entry.Deleted ? 1 : 0; + await db.Database.ExecuteSqlAsync( + $"INSERT INTO \"SyncOutbox\" (\"Table\", \"LocalId\", \"CloudId\", \"Deleted\", \"QueuedAt\") VALUES ({table}, {localId}, {cloudId}, {deleted}, {queuedAt})", + cancellationToken); + } + if (state.Transaction is not null) + await state.Transaction.CommitAsync(cancellationToken); + if (state.Entries.Count > 0 || state.ManualOutbox) signal.Notify(); + } + catch + { + if (state.Transaction is not null) + await state.Transaction.RollbackAsync(CancellationToken.None); + throw; + } + finally + { + if (state.Transaction is not null) + await state.Transaction.DisposeAsync(); + states.Remove(db); + } + } + + private void Fail(AppDbContext db) + { + if (!states.TryGetValue(db, out var state)) return; + try { state.Transaction?.Rollback(); } + finally + { + state.Transaction?.Dispose(); + states.Remove(db); + } + } + + private async Task FailAsync(AppDbContext db, CancellationToken cancellationToken) + { + if (!states.TryGetValue(db, out var state)) return; + try + { + if (state.Transaction is not null) + await state.Transaction.RollbackAsync(CancellationToken.None); + } + finally + { + if (state.Transaction is not null) + await state.Transaction.DisposeAsync(); + states.Remove(db); + } + } + + private static void ExpandCascades(AppDbContext db, State state) + { + var tables = new SyncModel(db).Tables.ToDictionary(table => table.ClrType); + var queue = new Queue<(Type Type, int Id)>(state.Entries + .Where(entry => entry.Deleted) + .Select(entry => (entry.Entry!.Metadata.ClrType, entry.LocalId))); + var seen = queue.ToHashSet(); + while (queue.TryDequeue(out var parent)) + { + var entity = db.Model.FindEntityType(parent.Type)!; + foreach (var key in entity.GetReferencingForeignKeys() + .Where(key => key.DeleteBehavior == DeleteBehavior.Cascade && + key.Properties.Count == 1 && + tables.ContainsKey(key.DeclaringEntityType.ClrType))) + { + var child = tables[key.DeclaringEntityType.ClrType]; + foreach (var id in DependentIds(db, child.Name, key.Properties[0].Name, parent.Id)) + { + var candidate = (child.ClrType, id); + if (!seen.Add(candidate)) continue; + queue.Enqueue(candidate); + state.Entries.Add(new Pending(null, child.Name, id, true, null)); + } + } + } + } + + private static async Task ExpandCascadesAsync( + AppDbContext db, State state, CancellationToken cancellationToken) + { + var tables = new SyncModel(db).Tables.ToDictionary(table => table.ClrType); + var queue = new Queue<(Type Type, int Id)>(state.Entries + .Where(entry => entry.Deleted) + .Select(entry => (entry.Entry!.Metadata.ClrType, entry.LocalId))); + var seen = queue.ToHashSet(); + while (queue.TryDequeue(out var parent)) + { + var entity = db.Model.FindEntityType(parent.Type)!; + foreach (var key in entity.GetReferencingForeignKeys() + .Where(key => key.DeleteBehavior == DeleteBehavior.Cascade && + key.Properties.Count == 1 && + tables.ContainsKey(key.DeclaringEntityType.ClrType))) + { + var child = tables[key.DeclaringEntityType.ClrType]; + var ids = await DependentIdsAsync( + db, child.Name, key.Properties[0].Name, parent.Id, cancellationToken); + foreach (var id in ids) + { + var candidate = (child.ClrType, id); + if (!seen.Add(candidate)) continue; + queue.Enqueue(candidate); + state.Entries.Add(new Pending(null, child.Name, id, true, null)); + } + } + } + } + + private static List DependentIds( + AppDbContext db, string table, string column, int parentId) + { + using var command = DependentCommand(db, table, column, parentId); + using var reader = command.ExecuteReader(); + var ids = new List(); + while (reader.Read()) ids.Add(reader.GetInt32(0)); + return ids; + } + + private static async Task> DependentIdsAsync( + AppDbContext db, string table, string column, int parentId, + CancellationToken cancellationToken) + { + await using var command = DependentCommand(db, table, column, parentId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + var ids = new List(); + while (await reader.ReadAsync(cancellationToken)) ids.Add(reader.GetInt32(0)); + return ids; + } + + private static DbCommand DependentCommand( + AppDbContext db, string table, string column, int parentId) + { + var command = db.Database.GetDbConnection().CreateCommand(); + command.Transaction = db.Database.CurrentTransaction?.GetDbTransaction(); + command.CommandText = $"SELECT \"Id\" FROM \"{table.Replace("\"", "\"\"")}\" " + + $"WHERE \"{column.Replace("\"", "\"\"")}\" = @parent"; + var parameter = command.CreateParameter(); + parameter.ParameterName = "@parent"; + parameter.Value = parentId; + command.Parameters.Add(parameter); + return command; + } +} diff --git a/BiStudent.Server/Sync/SyncModel.cs b/BiStudent.Server/Sync/SyncModel.cs new file mode 100644 index 0000000..c42f3b7 --- /dev/null +++ b/BiStudent.Server/Sync/SyncModel.cs @@ -0,0 +1,91 @@ +using BiStudent.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace BiStudent.Server.Sync; + +public sealed record SyncForeignKey( + IProperty Property, string PrincipalTable, bool IsRequired); + +public sealed record SyncTable( + string Name, Type ClrType, IReadOnlyList Properties, + IReadOnlyList ForeignKeys, IReadOnlyList UniqueIndexes); + +public sealed class SyncModel +{ + private static readonly HashSet ExcludedTeacherProperties = + [ + nameof(Teacher.PasswordHash), + nameof(Teacher.SecurityStamp), + nameof(Teacher.LastLoginAt), + nameof(Teacher.ClaimToken) + ]; + + public IReadOnlyList Tables { get; } + + public SyncModel(AppDbContext db) + { + var types = db.Model.GetEntityTypes() + .Where(type => type.ClrType != typeof(AppSetting) && + type.ClrType != typeof(SyncLink) && + type.ClrType != typeof(SyncOutboxEntry) && + type.ClrType != typeof(SyncAlias)) + .ToDictionary(type => type.ClrType); + var tables = new Dictionary(); + foreach (var type in types.Values) + { + var key = type.FindPrimaryKey(); + if (key?.Properties.Count != 1 || + key.Properties[0].Name != "Id" || + key.Properties[0].ClrType != typeof(int)) + throw new InvalidOperationException( + $"У {type.ClrType.Name} ожидается первичный ключ int Id."); + + var mappedKeys = type.GetForeignKeys().ToArray(); + if (mappedKeys.Any(foreignKey => foreignKey.Properties.Count != 1 || + !types.ContainsKey(foreignKey.PrincipalEntityType.ClrType))) + throw new InvalidOperationException( + $"Внешний ключ {type.ClrType.Name} не поддерживается синхронизацией."); + var foreignKeys = mappedKeys.Select(key => new SyncForeignKey( + key.Properties[0], key.PrincipalEntityType.GetTableName()!, key.IsRequired)) + .ToList(); + if (type.ClrType == typeof(FaceSubmission)) + { + var reviewedBy = type.FindProperty(nameof(FaceSubmission.ReviewedByTeacherId)) + ?? throw new InvalidOperationException("Поле преподавателя не найдено."); + foreignKeys.Add(new SyncForeignKey( + reviewedBy, types[typeof(Teacher)].GetTableName()!, false)); + } + var foreignNames = foreignKeys.Select(key => key.Property.Name).ToHashSet(); + var properties = type.GetProperties() + .Where(property => property.Name != "Id" && + !foreignNames.Contains(property.Name) && + (type.ClrType != typeof(Teacher) || + !ExcludedTeacherProperties.Contains(property.Name))) + .ToArray(); + tables.Add(type.ClrType, new SyncTable(type.GetTableName() ?? + throw new InvalidOperationException("У таблицы нет имени."), + type.ClrType, properties, foreignKeys, + type.GetIndexes().Where(index => index.IsUnique).ToArray())); + } + + var ordered = new List(); + var visiting = new HashSet(); + var visited = new HashSet(); + foreach (var table in tables.Values.OrderBy(table => table.Name)) + Visit(table); + Tables = ordered; + + void Visit(SyncTable table) + { + if (visited.Contains(table.ClrType)) return; + if (!visiting.Add(table.ClrType)) + throw new InvalidOperationException("Цикл внешних ключей при синхронизации."); + foreach (var key in table.ForeignKeys) + Visit(tables.Values.Single(parent => parent.Name == key.PrincipalTable)); + visiting.Remove(table.ClrType); + visited.Add(table.ClrType); + ordered.Add(table); + } + } +} diff --git a/BiStudent.Server/Sync/SyncService.cs b/BiStudent.Server/Sync/SyncService.cs new file mode 100644 index 0000000..2a5d64d --- /dev/null +++ b/BiStudent.Server/Sync/SyncService.cs @@ -0,0 +1,957 @@ +using System.Globalization; +using System.Linq.Expressions; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using BiStudent.Server.Data; +using BiStudent.Server.Firebase; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.Options; + +namespace BiStudent.Server.Sync; + +public enum SyncState { Disabled, NotSignedIn, Offline, Ok, Error } + +public sealed record SyncStatus( + SyncState State, DateTimeOffset? LastSuccessAt, int Pending, string? Error); + +public sealed record SyncReport( + SyncState State, int Sent, int Received, bool FacesChanged, int Pending, string? Error); + +public sealed class SyncService( + IServiceScopeFactory scopes, FirestoreClient firestore, FirebaseSessionStore sessions, + IOptions options, IDataProtectionProvider protection, + ILogger logger, TimeProvider clock) +{ + private sealed record UploadItem( + FirestoreWrite Write, List Entries, SyncLink? Link); + private enum ApplyResult { Applied, Ignored, Deferred } + + private static readonly MethodInfo LoadMethod = typeof(SyncService) + .GetMethod(nameof(LoadCoreAsync), BindingFlags.NonPublic | BindingFlags.Static)!; + private static readonly MethodInfo MissingMethod = typeof(SyncService) + .GetMethod(nameof(MissingCoreAsync), BindingFlags.NonPublic | BindingFlags.Static)!; + private static readonly MethodInfo UniqueMethod = typeof(SyncService) + .GetMethod(nameof(FindUniqueCoreAsync), BindingFlags.NonPublic | BindingFlags.Static)!; + private static readonly MethodInfo DependentsMethod = typeof(SyncService) + .GetMethod(nameof(FindDependentsCoreAsync), BindingFlags.NonPublic | BindingFlags.Static)!; + + private readonly SemaphoreSlim gate = new(1, 1); + private readonly FirebaseOptions settings = options.Value; + private readonly IDataProtector images = protection.CreateProtector("BiStudent.FaceSamples"); + private SyncStatus status = new(options.Value.Enabled ? SyncState.NotSignedIn : SyncState.Disabled, + null, 0, null); + + public event Action? FacesChanged; + public SyncStatus Status => status; + + public void Reset() + { + using var scope = scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.SyncOutbox.Where(entry => entry.Error != null) + .ExecuteUpdate(setters => setters.SetProperty(entry => entry.Error, (string?)null)); + status = new SyncStatus(settings.Enabled ? SyncState.NotSignedIn : SyncState.Disabled, + null, status.Pending, null); + } + + public async Task PullAsync( + IReadOnlyCollection tables, CancellationToken ct, FirebaseSession? session = null) + { + await gate.WaitAsync(ct); + var facesChanged = false; + try + { + session ??= await sessions.GetAsync(ct) + ?? throw new InvalidOperationException("Для загрузки данных нужен вход в Firebase."); + using var scope = scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var selected = new SyncModel(db).Tables + .Where(table => tables.Contains(table.Name)).ToArray(); + if (selected.Length != tables.Distinct().Count()) + throw new ArgumentException("Неизвестная таблица синхронизации.", nameof(tables)); + await ReceiveAsync(db, selected, session.IdToken, () => facesChanged = true, ct); + } + finally + { + if (facesChanged) FacesChanged?.Invoke(); + gate.Release(); + } + } + + public async Task EnsureLinkAsync(AppDbContext db, string table, int localId) + { + if (!new SyncModel(db).Tables.Any(candidate => candidate.Name == table)) + throw new ArgumentException("Неизвестная таблица синхронизации.", nameof(table)); + return (await EnsureLinkCoreAsync(db, table, localId, null, CancellationToken.None)).CloudId; + } + + private static async Task EnsureLinkCoreAsync( + AppDbContext db, string table, int localId, string? cloudId, + CancellationToken cancellationToken) + { + var link = await db.SyncLinks.FirstOrDefaultAsync(candidate => + candidate.Table == table && candidate.LocalId == localId, cancellationToken); + if (link is not null) return link; + link = new SyncLink + { + Table = table, LocalId = localId, + CloudId = cloudId ?? Guid.NewGuid().ToString("N") + }; + db.SyncLinks.Add(link); + try + { + await db.SaveChangesAsync(cancellationToken); + return link; + } + catch (DbUpdateException exception) when ( + exception.InnerException is SqliteException { SqliteErrorCode: 19 }) + { + db.Entry(link).State = EntityState.Detached; + var existing = await db.SyncLinks.FirstOrDefaultAsync(candidate => + candidate.Table == table && candidate.LocalId == localId, cancellationToken); + if (existing is null) throw; + return existing; + } + } + + public async Task BuildWriteAsync(AppDbContext db, string table, int localId) + { + var model = new SyncModel(db); + var selected = model.Tables.Single(candidate => candidate.Name == table); + var device = await GetOrCreateAsync(db, "sync:device", + () => Guid.NewGuid().ToString("N"), CancellationToken.None); + var item = await PrepareWriteAsync(db, selected, + [new SyncOutboxEntry { Table = table, LocalId = localId }], device, + CancellationToken.None); + return item?.Write ?? throw new InvalidOperationException("Не удалось подготовить документ."); + } + + public async Task RunAsync(CancellationToken cancellationToken = default) + { + await gate.WaitAsync(cancellationToken); + try + { + using var scope = scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var state = SyncState.Disabled; + var sent = 0; + var received = 0; + var facesChanged = false; + string? error = null; + int pending; + try + { + if (settings.Enabled) + { + var session = await sessions.GetAsync(cancellationToken); + if (session is null) state = SyncState.NotSignedIn; + else + { + var model = new SyncModel(db); + var device = await GetOrCreateAsync(db, "sync:device", + () => Guid.NewGuid().ToString("N"), cancellationToken); + var initialKey = "sync:initial-done:" + session.Uid; + if (await db.AppSettings.FindAsync([initialKey], cancellationToken) is null) + await QueueInitialAsync(db, model, cancellationToken); + + sent = await SendAsync(db, model, session, device, cancellationToken); + if (!await db.SyncOutbox.AnyAsync(cancellationToken) && + await db.AppSettings.FindAsync([initialKey], cancellationToken) is null) + await SetSettingAsync(db, initialKey, "true", cancellationToken); + + received = await ReceiveAsync(db, model.Tables, session.IdToken, + () => facesChanged = true, cancellationToken); + state = SyncState.Ok; + } + } + } + catch (FirebaseUnavailableException exception) + { + state = SyncState.Offline; + error = exception.Message; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + logger.LogError(exception, "Ошибка синхронизации Firebase"); + state = SyncState.Error; + error = exception.Message; + } + finally + { + pending = await db.SyncOutbox.CountAsync(CancellationToken.None); + var blocked = await db.SyncOutbox.CountAsync( + entry => entry.Error != null, CancellationToken.None); + if (blocked > 0) + { + var reason = await db.SyncOutbox + .Where(entry => entry.Error != null) + .OrderBy(entry => entry.Id) + .Select(entry => entry.Error) + .FirstAsync(CancellationToken.None); + error = $"{blocked} изменений не удаётся отправить: {reason}" + + (error is null ? "" : $"; {error}"); + } + status = new SyncStatus(state, + state == SyncState.Ok ? clock.GetUtcNow() : status.LastSuccessAt, + pending, error); + if (facesChanged) FacesChanged?.Invoke(); + } + return Report(state, sent, received, facesChanged, pending, error); + } + finally + { + gate.Release(); + } + } + + private SyncReport Report( + SyncState state, int sent, int received, bool facesChanged, int pending, string? error) + { + status = new SyncStatus(state, + state == SyncState.Ok ? clock.GetUtcNow() : status.LastSuccessAt, pending, error); + return new SyncReport(state, sent, received, facesChanged, pending, error); + } + + private static async Task GetOrCreateAsync( + AppDbContext db, string key, Func create, CancellationToken cancellationToken) + { + var setting = await db.AppSettings.FindAsync([key], cancellationToken); + if (setting is not null) return setting.Value; + var value = create(); + db.AppSettings.Add(new AppSetting { Key = key, Value = value }); + await db.SaveChangesAsync(cancellationToken); + return value; + } + + private static async Task SetSettingAsync( + AppDbContext db, string key, string value, CancellationToken cancellationToken) + { + var setting = await db.AppSettings.FindAsync([key], cancellationToken); + if (setting is null) + db.AppSettings.Add(new AppSetting { Key = key, Value = value }); + else setting.Value = value; + await db.SaveChangesAsync(cancellationToken); + } + + private static async Task QueueInitialAsync( + AppDbContext db, SyncModel model, CancellationToken cancellationToken) + { + foreach (var table in model.Tables) + { + var ids = await MissingAsync(db, table, cancellationToken); + foreach (var id in ids) + db.SyncOutbox.Add(new SyncOutboxEntry { Table = table.Name, LocalId = id }); + if (ids.Count > 0) await db.SaveChangesAsync(cancellationToken); + } + } + + private async Task SendAsync( + AppDbContext db, SyncModel model, FirebaseSession session, string device, + CancellationToken cancellationToken) + { + var sent = 0; + var teacherId = sessions.TeacherId; + var isAdmin = teacherId is not null && await db.Teachers.AsNoTracking() + .AnyAsync(teacher => teacher.Id == teacherId && + teacher.FirebaseUid == session.Uid && teacher.IsAdmin, cancellationToken); + var limitedUid = isAdmin ? null : session.Uid; + foreach (var deleted in new[] { true, false }) + { + var tables = deleted ? model.Tables.Reverse() : model.Tables; + foreach (var table in tables) + { + var entries = await db.SyncOutbox + .Where(entry => entry.Table == table.Name && entry.Deleted == deleted && + entry.Error == null) + .OrderBy(entry => entry.Id).ToListAsync(cancellationToken); + var groups = entries.GroupBy(entry => deleted + ? "D:" + entry.CloudId : "U:" + entry.LocalId.ToString(CultureInfo.InvariantCulture)) + .Select(group => group.ToList()).ToArray(); + var items = new List(); + var bytes = 13; + var refused = false; + foreach (var group in groups) + { + var item = await PrepareWriteAsync(db, table, group, device, + cancellationToken, limitedUid); + if (item is null) continue; + var size = WriteSize(item.Write) + 1; + if (items.Count == 400 || bytes + size > 8_000_000) + { + var result = await SendPortionAsync(db, session.IdToken, items, cancellationToken); + sent += result.Sent; + refused = result.Refused; + items.Clear(); + bytes = 13; + if (refused) break; + } + items.Add(item); + bytes += size; + } + if (!refused && items.Count > 0) + { + var result = await SendPortionAsync(db, session.IdToken, items, cancellationToken); + sent += result.Sent; + } + } + } + return sent; + } + + private int WriteSize(FirestoreWrite write) + { + var name = $"projects/{settings.ProjectId}/databases/(default)/documents/" + + write.Collection + "/" + write.Id; + var fields = write.Fields!.ToDictionary( + field => field.Key, field => FirestoreValues.ToValue(field.Value)); + var item = new Dictionary + { + ["update"] = new { name, fields } + }; + if (write.UpdateMask is not null) + item["updateMask"] = new { fieldPaths = write.UpdateMask }; + if (write.ServerTimestamp) + item["updateTransforms"] = new[] + { + new { fieldPath = "_updatedAt", setToServerValue = "REQUEST_TIME" } + }; + return JsonSerializer.SerializeToUtf8Bytes(item).Length; + } + + private async Task<(int Sent, bool Refused)> SendPortionAsync( + AppDbContext db, string token, List items, + CancellationToken cancellationToken) + { + try + { + var results = await firestore.CommitAsync(token, + items.Select(item => item.Write).ToArray(), cancellationToken); + for (var i = 0; i < items.Count; i++) + ApplyWriteResult(db, items[i], + i < results.Count ? results[i].UpdateTime : null); + await db.SaveChangesAsync(cancellationToken); + return (items.Count, false); + } + catch (FirestorePermissionException) + { + var sent = 0; + foreach (var item in items) + { + try + { + var results = await firestore.CommitAsync( + token, [item.Write], cancellationToken); + ApplyWriteResult(db, item, + results.Count > 0 ? results[0].UpdateTime : null); + await db.SaveChangesAsync(cancellationToken); + sent++; + } + catch (FirestorePermissionException exception) + { + logger.LogWarning(exception, "Нет прав на документ {Table}/{Id}", + item.Write.Collection, item.Write.Id); + if (item.Write.Collection == "Teachers") + db.SyncOutbox.RemoveRange(item.Entries); + else + foreach (var entry in item.Entries) + entry.Error = $"Нет прав на изменение {item.Write.Collection}/{item.Write.Id}."; + await db.SaveChangesAsync(cancellationToken); + } + } + return (sent, true); + } + } + + private async Task PrepareWriteAsync( + AppDbContext db, SyncTable table, List entries, string device, + CancellationToken cancellationToken, string? limitedUid = null) + { + var first = entries[0]; + if (first.Deleted) + { + if (first.CloudId is null || table.ClrType == typeof(Teacher) && + limitedUid is not null) + { + db.SyncOutbox.RemoveRange(entries); + await db.SaveChangesAsync(cancellationToken); + return null; + } + return new UploadItem(new FirestoreWrite(table.Name, first.CloudId, + new Dictionary + { + ["_deleted"] = true, + ["_device"] = device + }), entries, null); + } + + var link = await db.SyncLinks.FirstOrDefaultAsync(candidate => + candidate.Table == table.Name && candidate.LocalId == first.LocalId, + cancellationToken); + if (link is null) + { + await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); + link = await EnsureLinkCoreAsync( + db, table.Name, first.LocalId, null, cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + + var row = await LoadAsync(db, table, first.LocalId, true, cancellationToken); + if (row is null) + { + db.SyncOutbox.RemoveRange(entries); + db.SyncLinks.Remove(link); + await db.SaveChangesAsync(cancellationToken); + return null; + } + if (row is Teacher limitedTeacher && limitedUid is not null) + { + if (limitedTeacher.FirebaseUid != limitedUid) + { + db.SyncOutbox.RemoveRange(entries); + await db.SaveChangesAsync(cancellationToken); + return null; + } + return new UploadItem(new FirestoreWrite(table.Name, link.CloudId, + new Dictionary + { + ["MustChangePassword"] = limitedTeacher.MustChangePassword, + ["_device"] = device + }, ["MustChangePassword", "_device"]), entries, link); + } + if (row is Teacher unclaimed && unclaimed.FirebaseUid is null && + unclaimed.ClaimToken is null) + { + unclaimed.ClaimToken = Convert.ToHexString( + RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); + var applyingRemote = db.ApplyingRemote; + db.ApplyingRemote = true; + try { await db.SaveChangesAsync(cancellationToken); } + finally { db.ApplyingRemote = applyingRemote; } + } + + var fields = new Dictionary + { + ["_deleted"] = false, + ["_device"] = device + }; + foreach (var property in table.Properties) + { + var value = property.PropertyInfo?.GetValue(row); + if (property.Name == "ProtectedImage" && + table.ClrType is var type && + (type == typeof(FaceSample) || type == typeof(FaceSubmissionImage))) + { + try { fields["Image"] = images.Unprotect((byte[])value!); } + catch (CryptographicException exception) + { + logger.LogWarning(exception, "Не удалось прочитать изображение {Table}/{Id}", + table.Name, first.LocalId); + foreach (var entry in entries) entry.Error = "Не удалось расшифровать изображение."; + await db.SaveChangesAsync(cancellationToken); + return null; + } + } + else fields[property.Name] = value; + } + if (row is Teacher teacher) + { + fields["Email"] = settings.EmailFor(teacher.Login); + fields["ClaimHash"] = teacher.ClaimToken is null ? null : + SHA256.HashData(Encoding.UTF8.GetBytes(teacher.ClaimToken)); + } + + foreach (var key in table.ForeignKeys) + { + var property = key.Property; + var local = property.PropertyInfo?.GetValue(row); + if (local is null) + { + fields[property.Name] = null; + continue; + } + var parentTable = key.PrincipalTable; + var parentId = Convert.ToInt32(local, CultureInfo.InvariantCulture); + var parent = await db.SyncLinks.FirstOrDefaultAsync(link => + link.Table == parentTable && link.LocalId == parentId, cancellationToken); + if (parent is null) + { + if (!key.IsRequired) + { + fields[property.Name] = null; + continue; + } + if (!await db.SyncOutbox.AnyAsync(entry => + entry.Table == parentTable && entry.LocalId == parentId && + !entry.Deleted, cancellationToken)) + { + db.SyncOutbox.Add(new SyncOutboxEntry + { + Table = parentTable, + LocalId = parentId + }); + await db.SaveChangesAsync(cancellationToken); + } + return null; + } + fields[property.Name] = parent.CloudId; + } + + var size = JsonSerializer.SerializeToUtf8Bytes(fields.ToDictionary( + field => field.Key, field => FirestoreValues.ToValue(field.Value))).Length; + if (size > 900_000) + { + logger.LogWarning("Документ {Table}/{Id} слишком большой для Firestore", + table.Name, first.LocalId); + foreach (var entry in entries) entry.Error = "Документ слишком большой для Firestore."; + await db.SaveChangesAsync(cancellationToken); + return null; + } + + return new UploadItem(new FirestoreWrite(table.Name, link.CloudId, fields), + entries, link); + } + + private static void ApplyWriteResult( + AppDbContext db, UploadItem item, DateTimeOffset? updated) + { + db.SyncOutbox.RemoveRange(item.Entries); + if (item.Entries[0].Deleted) + { + var link = db.SyncLinks.Local.FirstOrDefault(candidate => + candidate.Table == item.Write.Collection && + candidate.CloudId == item.Write.Id); + if (link is not null) db.SyncLinks.Remove(link); + else + { + var existing = db.SyncLinks.FirstOrDefault(candidate => + candidate.Table == item.Write.Collection && + candidate.CloudId == item.Write.Id); + if (existing is not null) db.SyncLinks.Remove(existing); + } + } + else if (item.Link is not null) + item.Link.CloudUpdatedAt = updated; + } + + private async Task ReceiveAsync( + AppDbContext db, IReadOnlyList tables, string token, + Action facesChanged, CancellationToken cancellationToken) + { + var received = 0; + foreach (var table in tables) + { + var cursorKey = "sync:cursor:" + table.Name; + var setting = await db.AppSettings.FindAsync([cursorKey], cancellationToken); + DateTimeOffset? cursor = setting is null ? null : + DateTimeOffset.Parse(setting.Value, CultureInfo.InvariantCulture); + var after = cursor?.AddMinutes(-2); + FirestoreDocument? position = null; + DateTimeOffset? blockedAt = null; + while (true) + { + var page = await firestore.QueryChangedAsync( + token, table.Name, after, 300, cancellationToken, position); + if (page.Count == 0) break; + foreach (var document in page) + { + if (document.UpdatedAt is null) continue; + var result = await ApplyDocumentAsync(db, table, document, cancellationToken); + if (result == ApplyResult.Deferred) + { + blockedAt = document.UpdatedAt; + break; + } + if (result == ApplyResult.Applied) + { + received++; + if (table.ClrType == typeof(Student) || + table.ClrType == typeof(FaceSample)) + facesChanged(); + } + if (cursor is null || document.UpdatedAt > cursor) + cursor = document.UpdatedAt; + } + if (blockedAt is not null) break; + position = page[^1]; + if (page.Count < 300 || position.UpdatedAt is null) break; + } + if (blockedAt is not null && (cursor is null || cursor >= blockedAt)) + cursor = blockedAt.Value.AddMilliseconds(-1); + if (cursor is not null) + await SetSettingAsync(db, cursorKey, + cursor.Value.ToString("O", CultureInfo.InvariantCulture), + cancellationToken); + } + return received; + } + + private async Task ApplyDocumentAsync( + AppDbContext db, SyncTable table, FirestoreDocument document, + CancellationToken cancellationToken) + { + var deleted = document.Fields.TryGetValue("_deleted", out var flag) && + flag.TryGetProperty("booleanValue", out var boolean) && boolean.GetBoolean(); + await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); + db.ApplyingRemote = true; + try + { + var link = await db.SyncLinks.FirstOrDefaultAsync(candidate => + candidate.Table == table.Name && candidate.CloudId == document.Id, + cancellationToken); + if (link is null && await db.SyncAliases.AnyAsync(alias => + alias.Table == table.Name && alias.CloudId == document.Id, cancellationToken)) + return ApplyResult.Ignored; + if (link?.CloudUpdatedAt >= document.UpdatedAt) return ApplyResult.Ignored; + if (link is null && await db.SyncOutbox.AnyAsync(entry => + entry.Table == table.Name && entry.CloudId == document.Id && + entry.Deleted, cancellationToken)) + return ApplyResult.Deferred; + if (deleted) + { + if (link is null) return ApplyResult.Ignored; + if (await db.SyncOutbox.AnyAsync(entry => + entry.Table == table.Name && entry.LocalId == link.LocalId, + cancellationToken)) + return ApplyResult.Deferred; + var row = await LoadAsync(db, table, link!.LocalId, true, cancellationToken); + if (row is not null) db.Remove(row); + db.SyncLinks.Remove(link); + await db.SaveChangesAsync(cancellationToken); + } + else + { + var values = new Dictionary(); + var aliasUsed = false; + try + { + foreach (var property in table.Properties) + { + var name = property.Name == "ProtectedImage" && + (table.ClrType == typeof(FaceSample) || + table.ClrType == typeof(FaceSubmissionImage)) + ? "Image" : property.Name; + if (!document.Fields.TryGetValue(name, out var value)) continue; + var converted = FirestoreValues.FromValue(value, property.ClrType); + if (name == "Image" && converted is byte[] bytes) + converted = images.Protect(bytes); + values[property.Name] = converted; + } + foreach (var key in table.ForeignKeys) + { + var property = key.Property; + if (!document.Fields.TryGetValue(property.Name, out var value)) continue; + if (value.TryGetProperty("nullValue", out _)) + { + if (key.IsRequired) return ApplyResult.Deferred; + values[property.Name] = null; + continue; + } + var cloudId = (string?)FirestoreValues.FromValue(value, typeof(string)); + var (parent, viaAlias) = await ResolveLinkAsync( + db, key.PrincipalTable, cloudId!, cancellationToken); + if (parent is null && key.IsRequired) + { + logger.LogDebug("Нет родителя {Table}/{Id} для {Child}/{ChildId}", + key.PrincipalTable, cloudId, table.Name, document.Id); + return ApplyResult.Deferred; + } + values[property.Name] = parent?.LocalId; + aliasUsed |= viaAlias; + } + } + catch (Exception exception) when (exception is FormatException or + InvalidCastException or OverflowException or KeyNotFoundException or + InvalidOperationException) + { + logger.LogWarning(exception, "Неверный формат документа {Table}/{Id}", + table.Name, document.Id); + return ApplyResult.Ignored; + } + + object? row = link is null ? null : + await LoadAsync(db, table, link.LocalId, true, cancellationToken); + if (row is null && link is null && table.ClrType == typeof(Teacher) && + values.TryGetValue(nameof(Teacher.Login), out var login) && + login is string teacherLogin) + { + var lower = teacherLogin.ToLowerInvariant(); + row = await db.Teachers.FirstOrDefaultAsync(teacher => + teacher.Login.ToLower() == lower, cancellationToken); + } + if (row is null && link is null) + { + foreach (var index in table.UniqueIndexes) + { + if (index.Properties.Any(property => !values.ContainsKey(property.Name) || + values[property.Name] is null)) continue; + row = await FindUniqueAsync(db, table, index, values, cancellationToken); + if (row is not null) break; + } + } + var old = row is null || link is not null ? null : + await db.SyncLinks.FirstOrDefaultAsync(candidate => + candidate.Table == table.Name && + candidate.LocalId == (int)db.Entry(row).Property("Id").CurrentValue!, + cancellationToken); + var teacherAdopt = link is null && row is Teacher { FirebaseUid: null }; + if (row is not null) + { + var rowId = (int)db.Entry(row).Property("Id").CurrentValue!; + var queued = await db.SyncOutbox + .Where(entry => entry.Table == table.Name && entry.LocalId == rowId) + .ToListAsync(cancellationToken); + if (queued.Count > 0 && !teacherAdopt) return ApplyResult.Deferred; + if (teacherAdopt) db.SyncOutbox.RemoveRange(queued); + } + if (table.ClrType == typeof(Teacher) && old is not null && + !teacherAdopt) + { + logger.LogWarning( + "Преподаватель {Id} уже связан с другим документом Firebase", + old.LocalId); + return ApplyResult.Ignored; + } + + var applyValues = true; + var queueRow = aliasUsed; + var replaceLink = teacherAdopt && old is not null; + if (old is not null) + { + var newer = teacherAdopt || old.CloudUpdatedAt is null || + document.UpdatedAt > old.CloudUpdatedAt; + if (teacherAdopt || string.CompareOrdinal(document.Id, old.CloudId) < 0) + { + replaceLink = true; + applyValues = newer; + queueRow |= !newer; + } + else + { + applyValues = newer; + queueRow |= newer; + } + } + + if (old is not null && !replaceLink) + { + await AddAliasAsync(db, table.Name, document.Id, old.CloudId, + cancellationToken); + db.SyncOutbox.Add(new SyncOutboxEntry + { + Table = table.Name, + LocalId = old.LocalId, + CloudId = document.Id, + Deleted = true + }); + } + if (replaceLink) + { + var oldId = old!.CloudId; + await AddAliasAsync(db, table.Name, oldId, document.Id, + cancellationToken); + db.SyncLinks.Remove(old); + await db.SaveChangesAsync(cancellationToken); + db.SyncOutbox.Add(new SyncOutboxEntry + { + Table = table.Name, + LocalId = old.LocalId, + CloudId = oldId, + Deleted = true + }); + } + + var inserted = row is null; + row ??= Activator.CreateInstance(table.ClrType) + ?? throw new InvalidOperationException("Не удалось создать сущность."); + if (applyValues && row is Teacher incomingTeacher && + values.TryGetValue(nameof(Teacher.FirebaseUid), out var uid) && + uid is string newUid && incomingTeacher.FirebaseUid != newUid) + incomingTeacher.PasswordHash = ""; + if (applyValues && row is Teacher teacher && !inserted && + (Changed(teacher.Login, values, nameof(Teacher.Login)) || + Changed(teacher.IsAdmin, values, nameof(Teacher.IsAdmin)) || + Changed(teacher.IsActive, values, nameof(Teacher.IsActive)))) + teacher.SecurityStamp = Guid.NewGuid().ToString("N"); + if (applyValues) + foreach (var (name, value) in values) + db.Entry(row).Property(name).CurrentValue = value; + if (inserted) + { + if (row is Teacher newTeacher) + { + newTeacher.PasswordHash = ""; + newTeacher.LastLoginAt = null; + } + db.Add(row); + await db.SaveChangesAsync(cancellationToken); + } + + var localId = (int)db.Entry(row).Property("Id").CurrentValue!; + if (link is null && (old is null || replaceLink)) + link = await EnsureLinkCoreAsync( + db, table.Name, localId, document.Id, cancellationToken); + else if (link is not null) link.LocalId = localId; + if (link is not null) link.CloudUpdatedAt = document.UpdatedAt; + if (queueRow) await QueueAsync(db, table.Name, localId, cancellationToken); + if (replaceLink || teacherAdopt) + await QueueDependentsAsync(db, table.Name, localId, cancellationToken); + await db.SaveChangesAsync(cancellationToken); + } + await transaction.CommitAsync(cancellationToken); + return ApplyResult.Applied; + } + catch (DbUpdateException exception) + { + await transaction.RollbackAsync(cancellationToken); + db.ChangeTracker.Clear(); + logger.LogWarning(exception, "Не удалось применить {Table}/{Id}", + table.Name, document.Id); + return ApplyResult.Deferred; + } + finally + { + db.ApplyingRemote = false; + } + } + + private static async Task AddAliasAsync( + AppDbContext db, string table, string cloudId, string canonicalCloudId, + CancellationToken cancellationToken) + { + if (db.SyncAliases.Local.Any(alias => + alias.Table == table && alias.CloudId == cloudId) || + await db.SyncAliases.AnyAsync(alias => + alias.Table == table && alias.CloudId == cloudId, cancellationToken)) + return; + db.SyncAliases.Add(new SyncAlias + { + Table = table, CloudId = cloudId, CanonicalCloudId = canonicalCloudId + }); + } + + private static async Task<(SyncLink? Link, bool ViaAlias)> ResolveLinkAsync( + AppDbContext db, string table, string cloudId, CancellationToken cancellationToken) + { + var visited = new HashSet(StringComparer.Ordinal); + var viaAlias = false; + while (visited.Add(cloudId)) + { + var link = await db.SyncLinks.FirstOrDefaultAsync(candidate => + candidate.Table == table && candidate.CloudId == cloudId, + cancellationToken); + if (link is not null) return (link, viaAlias); + var alias = await db.SyncAliases.FirstOrDefaultAsync(candidate => + candidate.Table == table && candidate.CloudId == cloudId, + cancellationToken); + if (alias is null) break; + cloudId = alias.CanonicalCloudId; + viaAlias = true; + } + return (null, viaAlias); + } + + private static async Task QueueAsync( + AppDbContext db, string table, int localId, CancellationToken cancellationToken) + { + if (db.SyncOutbox.Local.Any(entry => + entry.Table == table && entry.LocalId == localId && !entry.Deleted) || + await db.SyncOutbox.AnyAsync(entry => + entry.Table == table && entry.LocalId == localId && !entry.Deleted, + cancellationToken)) + return; + db.SyncOutbox.Add(new SyncOutboxEntry { Table = table, LocalId = localId }); + } + + private static async Task QueueDependentsAsync( + AppDbContext db, string table, int localId, CancellationToken cancellationToken) + { + foreach (var child in new SyncModel(db).Tables) + foreach (var key in child.ForeignKeys.Where(key => key.PrincipalTable == table)) + { + var ids = await (Task>)DependentsMethod.MakeGenericMethod(child.ClrType) + .Invoke(null, [db, key.Property, localId, cancellationToken])!; + foreach (var id in ids) + await QueueAsync(db, child.Name, id, cancellationToken); + } + } + + private static Task> FindDependentsCoreAsync( + AppDbContext db, IProperty property, int localId, + CancellationToken cancellationToken) where T : class + { + var parameter = Expression.Parameter(typeof(T), "row"); + var left = Expression.Call(typeof(EF), nameof(EF.Property), + [property.ClrType], parameter, Expression.Constant(property.Name)); + var right = Expression.Convert(Expression.Constant(localId), property.ClrType); + var predicate = Expression.Lambda>( + Expression.Equal(left, right), parameter); + return db.Set().AsNoTracking().Where(predicate) + .Select(row => EF.Property(row, "Id")).ToListAsync(cancellationToken); + } + + private static bool Changed(T current, Dictionary values, string name) => + values.TryGetValue(name, out var next) && !Equals(current, next); + + private static Task LoadAsync( + AppDbContext db, SyncTable table, int id, bool tracked, CancellationToken cancellationToken) => + (Task)LoadMethod.MakeGenericMethod(table.ClrType) + .Invoke(null, [db, id, tracked, cancellationToken])!; + + private static async Task LoadCoreAsync( + AppDbContext db, int id, bool tracked, CancellationToken cancellationToken) where T : class + { + IQueryable query = db.Set(); + if (!tracked) query = query.AsNoTracking(); + return await query.FirstOrDefaultAsync( + row => EF.Property(row, "Id") == id, cancellationToken); + } + + private static Task> MissingAsync( + AppDbContext db, SyncTable table, CancellationToken cancellationToken) => + (Task>)MissingMethod.MakeGenericMethod(table.ClrType) + .Invoke(null, [db, table.Name, cancellationToken])!; + + private static Task> MissingCoreAsync( + AppDbContext db, string table, CancellationToken cancellationToken) where T : class => + db.Set().AsNoTracking() + .Where(row => !db.SyncLinks.Any(link => + link.Table == table && link.LocalId == EF.Property(row, "Id")) && + !db.SyncOutbox.Any(entry => + entry.Table == table && entry.LocalId == EF.Property(row, "Id"))) + .Select(row => EF.Property(row, "Id")) + .ToListAsync(cancellationToken); + + private static Task FindUniqueAsync( + AppDbContext db, SyncTable table, IIndex index, + Dictionary values, CancellationToken cancellationToken) => + (Task)UniqueMethod.MakeGenericMethod(table.ClrType) + .Invoke(null, [db, index, values, cancellationToken])!; + + private static async Task FindUniqueCoreAsync( + AppDbContext db, IIndex index, Dictionary values, + CancellationToken cancellationToken) where T : class + { + var parameter = Expression.Parameter(typeof(T), "row"); + Expression? predicate = null; + foreach (var property in index.Properties) + { + var left = Expression.Call(typeof(EF), nameof(EF.Property), + [property.ClrType], parameter, Expression.Constant(property.Name)); + var right = Expression.Convert(Expression.Constant(values[property.Name]), + property.ClrType); + var equal = Expression.Equal(left, right); + predicate = predicate is null ? equal : Expression.AndAlso(predicate, equal); + } + return await db.Set().FirstOrDefaultAsync( + Expression.Lambda>(predicate!, parameter), cancellationToken); + } +} diff --git a/BiStudent.Server/Sync/SyncSignal.cs b/BiStudent.Server/Sync/SyncSignal.cs new file mode 100644 index 0000000..32df1d4 --- /dev/null +++ b/BiStudent.Server/Sync/SyncSignal.cs @@ -0,0 +1,55 @@ +namespace BiStudent.Server.Sync; + +public sealed class SyncSignal +{ + private readonly object gate = new(); + private TaskCompletionSource wake = NewSignal(); + private DateTimeOffset? firstNotify; + + public void Notify() + { + lock (gate) + { + firstNotify ??= DateTimeOffset.UtcNow; + wake.TrySetResult(); + } + } + + public async Task WaitAsync(TimeSpan max, CancellationToken cancellationToken = default) + { + var deadline = DateTimeOffset.UtcNow + max; + try + { + while (true) + { + Task? signal; + TimeSpan delay; + lock (gate) + { + var now = DateTimeOffset.UtcNow; + var ready = firstNotify?.AddSeconds(5); + var until = ready is not null && ready < deadline ? ready.Value : deadline; + if (now >= until) return; + delay = until - now; + signal = firstNotify is null ? wake.Task : null; + } + if (signal is null) + await Task.Delay(delay, cancellationToken); + else + await Task.WhenAny(signal, Task.Delay(delay, cancellationToken)) + .WaitAsync(cancellationToken); + } + } + finally + { + lock (gate) + { + firstNotify = null; + wake = NewSignal(); + } + } + } + + private static TaskCompletionSource NewSignal() => + new(TaskCreationOptions.RunContinuationsAsynchronously); +} diff --git a/BiStudent.Server/Sync/SyncWorker.cs b/BiStudent.Server/Sync/SyncWorker.cs new file mode 100644 index 0000000..17187d0 --- /dev/null +++ b/BiStudent.Server/Sync/SyncWorker.cs @@ -0,0 +1,53 @@ +using BiStudent.Server.Firebase; +using BiStudent.Server.Services; +using Microsoft.Extensions.Options; + +namespace BiStudent.Server.Sync; + +public sealed class SyncWorker( + SyncService sync, SyncSignal signal, BackupService backup, + IOptions options, ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!options.Value.Enabled) return; + try + { + await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + + while (!stoppingToken.IsCancellationRequested) + { + try + { + if (backup.TryEnterRequest()) + { + try { await sync.RunAsync(stoppingToken); } + finally { backup.ExitRequest(); } + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception exception) + { + logger.LogError(exception, "Ошибка фоновой синхронизации Firebase"); + } + + try + { + await signal.WaitAsync( + TimeSpan.FromSeconds(options.Value.SyncIntervalSeconds), stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + } + } +} diff --git a/BiStudent.Server/appsettings.Development.json b/BiStudent.Server/appsettings.Development.json index 60aedc2..33de9f4 100644 --- a/BiStudent.Server/appsettings.Development.json +++ b/BiStudent.Server/appsettings.Development.json @@ -1,4 +1,5 @@ { + "Firebase": { "ApiKey": "", "ProjectId": "" }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Information" } } diff --git a/BiStudent.Server/appsettings.json b/BiStudent.Server/appsettings.json index 825d1fd..60506e7 100644 --- a/BiStudent.Server/appsettings.json +++ b/BiStudent.Server/appsettings.json @@ -24,6 +24,12 @@ "Password": "teacher123", "TeacherName": "Ахметова Алия Маратовна" }, + "Firebase": { + "ApiKey": "AIzaSyDnWCr72glzBkeLb2_OnYM-7Bwef9V6Np8", + "ProjectId": "bistudent", + "EmailDomain": "bistudent.app", + "SyncIntervalSeconds": 60 + }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } } diff --git a/BiStudent.Tests/FirebaseEmulatorTests.cs b/BiStudent.Tests/FirebaseEmulatorTests.cs new file mode 100644 index 0000000..7690cb2 --- /dev/null +++ b/BiStudent.Tests/FirebaseEmulatorTests.cs @@ -0,0 +1,281 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using BiStudent.Contracts; +using BiStudent.Server.Data; +using BiStudent.Server.Endpoints; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace BiStudent.Tests; + +[CollectionDefinition("firebase-emulator", DisableParallelization = true)] +public sealed class FirebaseEmulatorCollection; + +[Collection("firebase-emulator")] +public sealed class FirebaseEmulatorTests : IDisposable +{ + private const string Project = "demo-bistudent"; + private readonly string directory = Path.Combine(Path.GetTempPath(), + $"BiStudent.FirebaseTests.{Guid.NewGuid():N}"); + private readonly string? authHost = Environment.GetEnvironmentVariable("FIREBASE_AUTH_EMULATOR_HOST"); + private readonly string? firestoreHost = Environment.GetEnvironmentVariable("FIRESTORE_EMULATOR_HOST"); + + public FirebaseEmulatorTests() => Directory.CreateDirectory(directory); + + [Fact] + public async Task FirstAdministratorMigratesAndSyncs() + { + await ClearAsync(); + using var factory = Factory("a"); + using var client = factory.CreateClient(); + await BootstrapAsync(client); + await RunAsync(client); + var status = await client.GetFromJsonAsync("/api/sync/status"); + Assert.NotNull(status); + Assert.Equal("Ok", status.State); + Assert.Equal(0, status.Pending); + using var http = new HttpClient(); + Assert.NotNull(await OwnerDocumentAsync(http, "meta/bootstrap")); + var teachers = await OwnerDocumentsAsync(http, "Teachers"); + var admin = Assert.Single(teachers); + var uid = admin.GetProperty("fields").GetProperty("FirebaseUid") + .GetProperty("stringValue").GetString(); + Assert.False(string.IsNullOrWhiteSpace(uid)); + Assert.NotNull(await OwnerDocumentAsync(http, $"TeacherUids/{uid}")); + } + + [Fact] + public async Task NewTeacherReceivesDataOnAnotherComputer() + { + await ClearAsync(); + using var first = Factory("a"); + using var firstClient = first.CreateClient(); + await BootstrapAsync(firstClient); + var group = await PostAsync(firstClient, "/api/groups", + new CreateGroupRequest("Облачная группа")); + await PostAsync(firstClient, "/api/students", + new SaveStudentRequest("Облачный студент", group.Id)); + var temporary = await PostAsync(firstClient, + "/api/admin/teachers", new SaveTeacherRequest("anna", "Анна Иванова", false, true)); + Assert.Equal("Ok", (await RunAsync(firstClient)).State); + + using var second = Factory("b"); + using var secondClient = second.CreateClient(); + Authorize(secondClient, await LoginAsync(secondClient, "anna", temporary.Password)); + Assert.Equal("Ok", (await RunAsync(secondClient)).State); + using var scope = second.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.True(await db.Groups.AnyAsync(candidate => candidate.Name == "Облачная группа")); + Assert.True(await db.Students.AnyAsync( + candidate => candidate.FullName == "Облачный студент")); + } + + [Fact] + public async Task RulesRejectUnknownAndDisabledTeachers() + { + await ClearAsync(); + using var factory = Factory("a"); + using var client = factory.CreateClient(); + await BootstrapAsync(client); + var temporary = await PostAsync(client, + "/api/admin/teachers", new SaveTeacherRequest("anna", "Анна Иванова", false, true)); + await RunAsync(client); + using var http = new HttpClient(); + var unknown = await SignUpAsync(http, "unknown@bistudent.app", "Unknown-pass-1"); + Assert.Equal(HttpStatusCode.Forbidden, + await ReadAsAsync(http, unknown, "Students/anything")); + var teacherToken = await FirebaseSignInAsync( + http, "anna@bistudent.app", temporary.Password); + Assert.Equal(HttpStatusCode.NotFound, + await ReadAsAsync(http, teacherToken, "Groups/anything")); + var accounts = await client.GetFromJsonAsync>( + "/api/admin/teachers"); + var teacher = Assert.Single(accounts!, candidate => candidate.Login == "anna"); + var disabled = await client.PutAsJsonAsync($"/api/admin/teachers/{teacher.Id}", + new SaveTeacherRequest("anna", "Анна Иванова", false, false)); + Assert.Equal(HttpStatusCode.OK, disabled.StatusCode); + await RunAsync(client); + Assert.Equal(HttpStatusCode.Forbidden, + await ReadAsAsync(http, teacherToken, "Groups/anything")); + } + + [Fact] + public async Task OfflineLoginUsesLocalPasswordCache() + { + await ClearAsync(); + using (var online = Factory("a")) + using (var client = online.CreateClient()) + await BootstrapAsync(client); + + using var offline = Factory("a", "127.0.0.1:1"); + using var offlineClient = offline.CreateClient(); + var known = await offlineClient.PostAsJsonAsync("/api/auth/login", + new LoginRequest("admin", "Admin-pass-1")); + Assert.Equal(HttpStatusCode.OK, known.StatusCode); + var unknown = await offlineClient.PostAsJsonAsync("/api/auth/login", + new LoginRequest("unknown", "Unknown-pass-1")); + Assert.Equal(HttpStatusCode.ServiceUnavailable, unknown.StatusCode); + } + + private async Task ClearAsync() + { + if (string.IsNullOrWhiteSpace(authHost) || + string.IsNullOrWhiteSpace(firestoreHost)) + Assert.Skip("Нужен Firebase Emulator: FIREBASE_AUTH_EMULATOR_HOST и FIRESTORE_EMULATOR_HOST."); + using var http = new HttpClient(); + using var documents = await http.DeleteAsync( + $"http://{firestoreHost}/emulator/v1/projects/{Project}/databases/(default)/documents"); + documents.EnsureSuccessStatusCode(); + using var accounts = await http.DeleteAsync( + $"http://{authHost}/emulator/v1/projects/{Project}/accounts"); + accounts.EnsureSuccessStatusCode(); + } + + private EmulatorFactory Factory(string name, string? overrideAuthHost = null) + { + var path = Path.Combine(directory, name); + Directory.CreateDirectory(path); + return new EmulatorFactory(path, overrideAuthHost ?? authHost!, firestoreHost!); + } + + private static async Task BootstrapAsync(HttpClient client) + { + Authorize(client, await LoginAsync(client, "admin", "admin")); + var changed = await client.PostAsJsonAsync("/api/auth/change-password", + new ChangePasswordRequest("admin", "Admin-pass-1")); + Assert.Equal(HttpStatusCode.OK, changed.StatusCode); + var token = await changed.Content.ReadFromJsonAsync(); + Assert.NotNull(token); + Authorize(client, token); + } + + private static async Task RunAsync(HttpClient client) + { + var response = await client.PostAsync("/api/sync/run", null); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var status = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(status); + return status; + } + + private static async Task PostAsync(HttpClient client, string path, object body) + { + var response = await client.PostAsJsonAsync(path, body); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + return result!; + } + + private static async Task LoginAsync( + HttpClient client, string login, string password) + { + var response = await client.PostAsJsonAsync( + "/api/auth/login", new LoginRequest(login, password)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var token = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(token); + return token; + } + + private static void Authorize(HttpClient client, TokenResponse token) => + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", token.AccessToken); + + private async Task OwnerDocumentAsync(HttpClient http, string path) + { + using var request = new HttpRequestMessage(HttpMethod.Get, + $"http://{firestoreHost}/v1/projects/{Project}/databases/(default)/documents/{path}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "owner"); + using var response = await http.SendAsync(request); + if (response.StatusCode == HttpStatusCode.NotFound) return null; + response.EnsureSuccessStatusCode(); + using var document = await JsonDocument.ParseAsync( + await response.Content.ReadAsStreamAsync()); + return document.RootElement.Clone(); + } + + private async Task> OwnerDocumentsAsync( + HttpClient http, string collection) + { + using var request = new HttpRequestMessage(HttpMethod.Get, + $"http://{firestoreHost}/v1/projects/{Project}/databases/(default)/documents/{collection}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "owner"); + using var response = await http.SendAsync(request); + response.EnsureSuccessStatusCode(); + using var document = await JsonDocument.ParseAsync( + await response.Content.ReadAsStreamAsync()); + return document.RootElement.GetProperty("documents").EnumerateArray() + .Select(item => item.Clone()).ToList(); + } + + private async Task SignUpAsync(HttpClient http, string email, string password) + { + using var response = await http.PostAsJsonAsync( + $"http://{authHost}/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake-key", + new { email, password, returnSecureToken = true }); + response.EnsureSuccessStatusCode(); + using var document = await JsonDocument.ParseAsync( + await response.Content.ReadAsStreamAsync()); + return document.RootElement.GetProperty("idToken").GetString()!; + } + + private async Task FirebaseSignInAsync( + HttpClient http, string email, string password) + { + using var response = await http.PostAsJsonAsync( + $"http://{authHost}/identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=fake-key", + new { email, password, returnSecureToken = true }); + response.EnsureSuccessStatusCode(); + using var document = await JsonDocument.ParseAsync( + await response.Content.ReadAsStreamAsync()); + return document.RootElement.GetProperty("idToken").GetString()!; + } + + private async Task ReadAsAsync( + HttpClient http, string token, string path) + { + using var request = new HttpRequestMessage(HttpMethod.Get, + $"http://{firestoreHost}/v1/projects/{Project}/databases/(default)/documents/{path}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + using var response = await http.SendAsync(request); + return response.StatusCode; + } + + public void Dispose() => Directory.Delete(directory, recursive: true); + + private sealed class EmulatorFactory( + string directory, string authHost, string firestoreHost) + : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseContentRoot(ServerRoot()); + builder.UseSetting("ConnectionStrings:Default", + $"Data Source={Path.Combine(directory, "test.db")};Pooling=False"); + builder.UseSetting("Demo:Enabled", "false"); + builder.UseSetting("Firebase:ApiKey", "fake-key"); + builder.UseSetting("Firebase:ProjectId", Project); + builder.UseSetting("Firebase:AuthEmulatorHost", authHost); + builder.UseSetting("Firebase:FirestoreEmulatorHost", firestoreHost); + builder.UseSetting("Firebase:SyncIntervalSeconds", "3600"); + } + + private static string ServerRoot() + { + for (var current = new DirectoryInfo(AppContext.BaseDirectory); + current is not null; current = current.Parent) + { + var candidate = Path.Combine(current.FullName, "BiStudent.Server"); + if (File.Exists(Path.Combine(candidate, "seed", "students.demo.csv"))) + return candidate; + } + throw new DirectoryNotFoundException("BiStudent.Server/seed/students.demo.csv"); + } + } +} diff --git a/BiStudent.Tests/FirebaseSyncScenarioTests.cs b/BiStudent.Tests/FirebaseSyncScenarioTests.cs new file mode 100644 index 0000000..386d7a9 --- /dev/null +++ b/BiStudent.Tests/FirebaseSyncScenarioTests.cs @@ -0,0 +1,465 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json.Nodes; +using BiStudent.Contracts; +using BiStudent.Server.Data; +using BiStudent.Server.Endpoints; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace BiStudent.Tests; + +[Collection("firebase-emulator")] +public sealed class FirebaseSyncScenarioTests : IDisposable +{ + private const string Project = "demo-bistudent"; + private readonly string directory = Path.Combine(Path.GetTempPath(), + $"BiStudent.FirebaseScenarios.{Guid.NewGuid():N}"); + private readonly string? authHost = Environment.GetEnvironmentVariable("FIREBASE_AUTH_EMULATOR_HOST"); + private readonly string? firestoreHost = Environment.GetEnvironmentVariable("FIRESTORE_EMULATOR_HOST"); + + public FirebaseSyncScenarioTests() => Directory.CreateDirectory(directory); + + [Fact] + public async Task ChangesTravelBothWaysIncludingEditsAndDeletion() + { + await ClearAsync(); + using var first = Factory("a"); + using var firstClient = first.CreateClient(); + await BootstrapAsync(firstClient); + var temporary = await PostAsync(firstClient, + "/api/admin/teachers", new SaveTeacherRequest("anna", "Анна Иванова", false, true)); + using (var scope = first.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Groups.Add(new Group { Name = "Общая группа" }); + await db.SaveChangesAsync(); + } + await RunAsync(firstClient); + + using var second = Factory("b"); + using var secondClient = second.CreateClient(); + Authorize(secondClient, await LoginAsync(secondClient, "anna", temporary.Password)); + await RunAsync(secondClient); + using (var scope = second.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var group = await db.Groups.SingleAsync(x => x.Name == "Общая группа"); + db.Students.Add(new Student { FullName = "Иванов Пётр", GroupId = group.Id }); + await db.SaveChangesAsync(); + } + await RunAsync(secondClient); + await RunAsync(firstClient); + + string cloudId; + using (var scope = first.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var student = await db.Students.SingleAsync(x => x.FullName == "Иванов Пётр"); + cloudId = await db.SyncLinks.Where(x => x.Table == "Students" && x.LocalId == student.Id) + .Select(x => x.CloudId).SingleAsync(); + student.FullName = "Иванов Пётр Сергеевич"; + await db.SaveChangesAsync(); + } + await RunAsync(firstClient); + await RunAsync(secondClient); + using (var scope = second.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + Assert.True(await db.Students.AnyAsync(x => x.FullName == "Иванов Пётр Сергеевич")); + } + + using (var scope = first.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var student = await db.Students.SingleAsync(x => x.FullName == "Иванов Пётр Сергеевич"); + db.Students.Remove(student); + await db.SaveChangesAsync(); + } + await RunAsync(firstClient); + await RunAsync(secondClient); + using (var scope = second.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + Assert.False(await db.Students.AnyAsync(x => x.FullName == "Иванов Пётр Сергеевич")); + } + using var http = new HttpClient(); + var tombstone = await OwnerDocumentAsync(http, $"Students/{cloudId}"); + Assert.NotNull(tombstone); + Assert.True(tombstone["fields"]!["_deleted"]!["booleanValue"]!.GetValue()); + } + + [Fact] + public async Task LegacyTeacherClaimsAccountAndSignsInOnAnotherComputer() + { + await ClearAsync(); + using var first = Factory("a"); + using var adminClient = first.CreateClient(); + await BootstrapAsync(adminClient); + await AddLegacyTeacherAsync(first); + await RunAsync(adminClient); + using var http = new HttpClient(); + var teacher = Assert.Single(await OwnerDocumentsAsync(http, "Teachers"), + document => Field(document, "Login") == "petrov"); + Assert.Null(teacher["fields"]?["ClaimToken"]); + Assert.NotNull(teacher["fields"]?["ClaimHash"]?["bytesValue"]); + + using var petrovClient = first.CreateClient(); + Authorize(petrovClient, await LoginAsync(petrovClient, "petrov", "Petrov-pass-1")); + string uid; + using (var scope = first.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + uid = (await db.Teachers.Where(x => x.Login == "petrov") + .Select(x => x.FirebaseUid).SingleAsync())!; + } + Assert.False(string.IsNullOrWhiteSpace(uid)); + Assert.NotNull(await OwnerDocumentAsync(http, $"TeacherUids/{uid}")); + var linked = await OwnerDocumentAsync(http, $"Teachers/{DocumentId(teacher)}"); + Assert.NotNull(linked); + Assert.Equal(uid, Field(linked, "FirebaseUid")); + + using var second = Factory("b"); + using var secondClient = second.CreateClient(); + Authorize(secondClient, await LoginAsync(secondClient, "petrov", "Petrov-pass-1")); + } + + [Fact] + public async Task ConcurrentDuplicateGroupsShareOneCloudDocument() + { + await ClearAsync(); + using var first = Factory("a"); + using var firstClient = first.CreateClient(); + await BootstrapAsync(firstClient); + var temporary = await PostAsync(firstClient, + "/api/admin/teachers", new SaveTeacherRequest("anna", "Анна Иванова", false, true)); + await RunAsync(firstClient); + + using var second = Factory("b"); + using var secondClient = second.CreateClient(); + Authorize(secondClient, await LoginAsync(secondClient, "anna", temporary.Password)); + await RunAsync(secondClient); + using (var scope = first.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Groups.Add(new Group { Name = "Дубль" }); + await db.SaveChangesAsync(); + } + using (var scope = second.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Groups.Add(new Group { Name = "Дубль" }); + await db.SaveChangesAsync(); + } + await RunAsync(firstClient); + await RunAsync(secondClient); + await RunAsync(firstClient); + await RunAsync(secondClient); + + using var http = new HttpClient(); + var documents = await OwnerDocumentsAsync(http, "Groups"); + var cloud = Assert.Single(documents, document => + Field(document, "Name") == "Дубль" && !Deleted(document)); + var cloudId = DocumentId(cloud); + foreach (var factory in new[] { first, second }) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var local = Assert.Single(await db.Groups.Where(x => x.Name == "Дубль").ToListAsync()); + var linkedId = await db.SyncLinks.Where(x => x.Table == "Groups" && x.LocalId == local.Id) + .Select(x => x.CloudId).SingleAsync(); + Assert.Equal(cloudId, linkedId); + } + } + + [Fact] + public async Task StrangerCannotClaimLegacyTeacher() + { + await ClearAsync(); + using var factory = Factory("a"); + using var client = factory.CreateClient(); + await BootstrapAsync(client); + await AddLegacyTeacherAsync(factory); + await RunAsync(client); + using var http = new HttpClient(); + var teacher = Assert.Single(await OwnerDocumentsAsync(http, "Teachers"), + document => Field(document, "Login") == "petrov"); + var account = await SignUpAsync(http, "petrov@bistudent.app", "Stranger-pass-1"); + var teacherId = DocumentId(teacher); + var falseProof = Guid.NewGuid().ToString("N"); + + Assert.Equal(HttpStatusCode.Forbidden, + await CommitClaimAsync(http, account.Token, teacherId, account.Uid, null)); + Assert.Equal(HttpStatusCode.Forbidden, + await CommitClaimAsync(http, account.Token, teacherId, account.Uid, falseProof)); + Assert.Equal(HttpStatusCode.Forbidden, + await CommitUidAsync(http, account.Token, teacherId, account.Uid)); + } + + [Fact] + public async Task SeededAdministratorUsesCloudPasswordOnNewComputer() + { + await ClearAsync(); + using var first = Factory("a"); + using var firstClient = first.CreateClient(); + await BootstrapAsync(firstClient); + await RunAsync(firstClient); + + using (var second = Factory("b")) + using (var secondClient = second.CreateClient()) + { + Authorize(secondClient, + await LoginAsync(secondClient, "admin", "Admin-pass-1")); + var me = await secondClient.GetFromJsonAsync("/api/me"); + Assert.NotNull(me); + Assert.True(me["isAdmin"]!.GetValue()); + } + + using var offline = Factory("b", "127.0.0.1:1"); + using var offlineClient = offline.CreateClient(); + var oldPassword = await offlineClient.PostAsJsonAsync( + "/api/auth/login", new LoginRequest("admin", "admin")); + Assert.NotEqual(HttpStatusCode.OK, oldPassword.StatusCode); + using var scope = offline.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var teacher = await db.Teachers.SingleAsync(candidate => candidate.Login == "admin"); + var hasher = scope.ServiceProvider.GetRequiredService>(); + Assert.NotEqual(PasswordVerificationResult.Failed, + hasher.VerifyHashedPassword(teacher, teacher.PasswordHash, "Admin-pass-1")); + Assert.Equal(PasswordVerificationResult.Failed, + hasher.VerifyHashedPassword(teacher, teacher.PasswordHash, "admin")); + } + + [Fact] + public async Task ResetPasswordLinksUnclaimedTeacher() + { + await ClearAsync(); + using var first = Factory("a"); + using var firstClient = first.CreateClient(); + await BootstrapAsync(firstClient); + int teacherId; + using (var scope = first.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var teacher = new Teacher { Login = "sidorov", FullName = "Сидоров" }; + var hasher = scope.ServiceProvider.GetRequiredService>(); + teacher.PasswordHash = hasher.HashPassword(teacher, "Sidorov-pass-1"); + db.Teachers.Add(teacher); + await db.SaveChangesAsync(); + teacherId = teacher.Id; + } + var temporary = await PostAsync(firstClient, + $"/api/admin/teachers/{teacherId}/reset-password", new { }); + await RunAsync(firstClient); + + using var second = Factory("b"); + using var secondClient = second.CreateClient(); + Authorize(secondClient, + await LoginAsync(secondClient, "sidorov", temporary.Password)); + } + + private async Task ClearAsync() + { + if (string.IsNullOrWhiteSpace(authHost) || + string.IsNullOrWhiteSpace(firestoreHost)) + Assert.Skip("Нужен Firebase Emulator: FIREBASE_AUTH_EMULATOR_HOST и FIRESTORE_EMULATOR_HOST."); + using var http = new HttpClient(); + using var documents = await http.DeleteAsync( + $"http://{firestoreHost}/emulator/v1/projects/{Project}/databases/(default)/documents"); + documents.EnsureSuccessStatusCode(); + using var accounts = await http.DeleteAsync( + $"http://{authHost}/emulator/v1/projects/{Project}/accounts"); + accounts.EnsureSuccessStatusCode(); + } + + private EmulatorFactory Factory(string name, string? overrideAuthHost = null) + { + var path = Path.Combine(directory, name); + Directory.CreateDirectory(path); + return new EmulatorFactory(path, overrideAuthHost ?? authHost!, firestoreHost!); + } + + private static async Task BootstrapAsync(HttpClient client) + { + Authorize(client, await LoginAsync(client, "admin", "admin")); + var changed = await client.PostAsJsonAsync("/api/auth/change-password", + new ChangePasswordRequest("admin", "Admin-pass-1")); + Assert.Equal(HttpStatusCode.OK, changed.StatusCode); + var token = await changed.Content.ReadFromJsonAsync(); + Assert.NotNull(token); + Authorize(client, token); + } + + private static async Task AddLegacyTeacherAsync(EmulatorFactory factory) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var teacher = new Teacher { Login = "petrov", FullName = "Петров", FirebaseUid = null }; + var hasher = scope.ServiceProvider.GetRequiredService>(); + teacher.PasswordHash = hasher.HashPassword(teacher, "Petrov-pass-1"); + db.Teachers.Add(teacher); + await db.SaveChangesAsync(); + } + + private static async Task RunAsync(HttpClient client) + { + var response = await client.PostAsync("/api/sync/run", null); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var status = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(status); + Assert.Equal("Ok", status.State); + } + + private static async Task PostAsync(HttpClient client, string path, object body) + { + var response = await client.PostAsJsonAsync(path, body); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + return result!; + } + + private static async Task LoginAsync( + HttpClient client, string login, string password) + { + var response = await client.PostAsJsonAsync( + "/api/auth/login", new LoginRequest(login, password)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var token = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(token); + return token; + } + + private static void Authorize(HttpClient client, TokenResponse token) => + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", token.AccessToken); + + private async Task OwnerDocumentAsync(HttpClient http, string path) + { + using var request = new HttpRequestMessage(HttpMethod.Get, + $"http://{firestoreHost}/v1/projects/{Project}/databases/(default)/documents/{path}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "owner"); + using var response = await http.SendAsync(request); + if (response.StatusCode == HttpStatusCode.NotFound) return null; + response.EnsureSuccessStatusCode(); + return JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsObject(); + } + + private async Task> OwnerDocumentsAsync(HttpClient http, string collection) + { + using var request = new HttpRequestMessage(HttpMethod.Get, + $"http://{firestoreHost}/v1/projects/{Project}/databases/(default)/documents/{collection}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "owner"); + using var response = await http.SendAsync(request); + response.EnsureSuccessStatusCode(); + var documents = JsonNode.Parse(await response.Content.ReadAsStringAsync())!["documents"]?.AsArray(); + return documents is null ? [] : documents.Select(item => item!.AsObject()).ToList(); + } + + private async Task<(string Uid, string Token)> SignUpAsync( + HttpClient http, string email, string password) + { + using var response = await http.PostAsJsonAsync( + $"http://{authHost}/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake-key", + new { email, password, returnSecureToken = true }); + response.EnsureSuccessStatusCode(); + var account = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + return (account["localId"]!.GetValue(), account["idToken"]!.GetValue()); + } + + private Task CommitClaimAsync( + HttpClient http, string token, string teacherId, string uid, string? proof) + { + var fields = new JsonObject { ["FirebaseUid"] = StringValue(uid) }; + var mask = new JsonArray(); + mask.Add("FirebaseUid"); + if (proof is not null) + { + fields["ClaimProof"] = StringValue(proof); + mask.Add("ClaimProof"); + } + return CommitAsync(http, token, new JsonObject + { + ["update"] = new JsonObject + { + ["name"] = DocumentName($"Teachers/{teacherId}"), + ["fields"] = fields + }, + ["updateMask"] = new JsonObject { ["fieldPaths"] = mask } + }); + } + + private Task CommitUidAsync( + HttpClient http, string token, string teacherId, string uid) => + CommitAsync(http, token, new JsonObject + { + ["update"] = new JsonObject + { + ["name"] = DocumentName($"TeacherUids/{uid}"), + ["fields"] = new JsonObject { ["teacher"] = StringValue(teacherId) } + } + }); + + private async Task CommitAsync( + HttpClient http, string token, JsonObject write) + { + using var request = new HttpRequestMessage(HttpMethod.Post, + $"http://{firestoreHost}/v1/projects/{Project}/databases/(default)/documents:commit") + { + Content = JsonContent.Create(new JsonObject { ["writes"] = new JsonArray(write) }) + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + using var response = await http.SendAsync(request); + return response.StatusCode; + } + + private static JsonObject StringValue(string value) => new() { ["stringValue"] = value }; + private static string DocumentName(string path) => + $"projects/{Project}/databases/(default)/documents/{path}"; + private static string DocumentId(JsonObject document) + { + var name = document["name"]!.GetValue(); + return name[(name.LastIndexOf('/') + 1)..]; + } + private static string Field(JsonObject document, string field) => + document["fields"]?[field]?["stringValue"]?.GetValue() ?? ""; + private static bool Deleted(JsonObject document) => + document["fields"]?["_deleted"]?["booleanValue"]?.GetValue() == true; + + public void Dispose() => Directory.Delete(directory, recursive: true); + + private sealed class EmulatorFactory( + string directory, string authHost, string firestoreHost) + : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseContentRoot(ServerRoot()); + builder.UseSetting("ConnectionStrings:Default", + $"Data Source={Path.Combine(directory, "test.db")};Pooling=False"); + builder.UseSetting("Demo:Enabled", "false"); + builder.UseSetting("Firebase:ApiKey", "fake-key"); + builder.UseSetting("Firebase:ProjectId", Project); + builder.UseSetting("Firebase:AuthEmulatorHost", authHost); + builder.UseSetting("Firebase:FirestoreEmulatorHost", firestoreHost); + builder.UseSetting("Firebase:SyncIntervalSeconds", "3600"); + } + + private static string ServerRoot() + { + for (var current = new DirectoryInfo(AppContext.BaseDirectory); + current is not null; current = current.Parent) + { + var candidate = Path.Combine(current.FullName, "BiStudent.Server"); + if (File.Exists(Path.Combine(candidate, "seed", "students.demo.csv"))) + return candidate; + } + throw new DirectoryNotFoundException("BiStudent.Server/seed/students.demo.csv"); + } + } +} diff --git a/BiStudent.Tests/FirestoreValuesTests.cs b/BiStudent.Tests/FirestoreValuesTests.cs new file mode 100644 index 0000000..529c879 --- /dev/null +++ b/BiStudent.Tests/FirestoreValuesTests.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using BiStudent.Server.Firebase; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class FirestoreValuesTests +{ + private enum SampleKind { First, Second } + + public static TheoryData Values => new() + { + { true, typeof(bool) }, + { 42, typeof(int) }, + { 9876543210L, typeof(long) }, + { 1.25, typeof(double) }, + { "текст", typeof(string) }, + { new byte[] { 0, 1, 255 }, typeof(byte[]) }, + { new DateTimeOffset(2026, 9, 1, 12, 34, 56, TimeSpan.Zero), typeof(DateTimeOffset) }, + { new DateTime(2026, 9, 1, 12, 34, 56, 123), typeof(DateTime) }, + { new DateOnly(2026, 9, 1), typeof(DateOnly) }, + { new TimeOnly(12, 34, 56, 123), typeof(TimeOnly) }, + { SampleKind.Second, typeof(SampleKind) }, + { SampleKind.First, typeof(SampleKind?) }, + { new DateOnly(2026, 9, 1), typeof(DateOnly?) } + }; + + [Theory] + [MemberData(nameof(Values))] + public void ValueRoundTrips(object original, Type target) + { + using var document = JsonDocument.Parse(FirestoreValues.ToValue(original).ToJsonString()); + var actual = FirestoreValues.FromValue(document.RootElement, target); + if (original is byte[] bytes) + Assert.Equal(bytes, Assert.IsType(actual)); + else + Assert.Equal(original, actual); + } + + [Fact] + public void NullRoundTrips() + { + using var document = JsonDocument.Parse(FirestoreValues.ToValue(null).ToJsonString()); + Assert.Null(FirestoreValues.FromValue(document.RootElement, typeof(DateOnly?))); + } + + [Fact] + public void IntegerValueMayBeString() + { + using var document = JsonDocument.Parse("""{"integerValue":"123456789"}"""); + Assert.Equal(123456789L, + FirestoreValues.FromValue(document.RootElement, typeof(long))); + } + + [Fact] + public void UnknownEnumMemberFails() + { + using var document = JsonDocument.Parse("""{"stringValue":"Missing"}"""); + Assert.Throws(() => + FirestoreValues.FromValue(document.RootElement, typeof(SampleKind))); + } +} diff --git a/BiStudent.Tests/SyncModelTests.cs b/BiStudent.Tests/SyncModelTests.cs new file mode 100644 index 0000000..b99c403 --- /dev/null +++ b/BiStudent.Tests/SyncModelTests.cs @@ -0,0 +1,32 @@ +using BiStudent.Server.Data; +using BiStudent.Server.Sync; +using Xunit; + +namespace BiStudent.Tests; + +public sealed class SyncModelTests +{ + [Fact] + public void TablesFollowForeignKeysAndExcludeLocalData() + { + using var database = new TestDb(); + var tables = new SyncModel(database.Db).Tables; + var names = tables.Select(table => table.Name).ToArray(); + + Assert.Contains("Groups", names); + Assert.Contains("Students", names); + Assert.Contains("Teachers", names); + Assert.Contains("ScheduleSlots", names); + Assert.Contains("AttendanceMarks", names); + Assert.DoesNotContain("AppSettings", names); + Assert.DoesNotContain("SyncLinks", names); + Assert.DoesNotContain("SyncOutbox", names); + + Assert.True(Array.IndexOf(names, "Groups") < Array.IndexOf(names, "Students")); + Assert.True(Array.IndexOf(names, "Teachers") < Array.IndexOf(names, "ScheduleSlots")); + Assert.True(Array.IndexOf(names, "ScheduleSlots") < + Array.IndexOf(names, "AttendanceMarks")); + Assert.DoesNotContain(tables.Single(table => table.ClrType == typeof(Teacher)) + .Properties, property => property.Name == nameof(Teacher.PasswordHash)); + } +} diff --git a/BiStudent/Api/ApiClient.cs b/BiStudent/Api/ApiClient.cs index ba4202e..ad49703 100644 --- a/BiStudent/Api/ApiClient.cs +++ b/BiStudent/Api/ApiClient.cs @@ -115,6 +115,9 @@ public async Task LoadMeAsync() public Task AutomationAsync() => GetAsync("api/settings/automation"); public Task RecognitionStatusAsync() => GetAsync("api/recognition/status"); + public Task SyncStatusAsync(CancellationToken cancellationToken = default) => + RequestAsync(HttpMethod.Get, "api/sync/status", cancellationToken: cancellationToken); + public Task SyncNowAsync() => RequestAsync(HttpMethod.Post, "api/sync/run"); public Task> BackupsAsync() => GetAsync>("api/admin/backups"); public Task CreateBackupAsync() => RequestAsync(HttpMethod.Post, "api/admin/backups", timeout: LongTimeout); diff --git a/BiStudent/MainForm.cs b/BiStudent/MainForm.cs index 2a1b4c4..18badd8 100644 --- a/BiStudent/MainForm.cs +++ b/BiStudent/MainForm.cs @@ -1,3 +1,4 @@ +using System.Net; using BiStudent.Contracts; namespace BiStudent; @@ -15,12 +16,16 @@ private static readonly (string Key, string Text, string Icon)[] Pages = private readonly Panel host = new() { Dock = DockStyle.Fill, BackColor = Theme.Background }; private readonly Dictionary nav = []; private readonly ContextMenuStrip userMenu = new() { BackColor = Theme.SurfaceHover, ForeColor = Theme.Text, Font = Theme.Font(10) }; + private readonly Label syncStatus = Ui.Label("", 8.5f, Theme.Muted); + private readonly ToolTip syncToolTip = new(); + private readonly System.Windows.Forms.Timer syncTimer = new() { Interval = 30000 }; private Page? current; private Label? userName; private Label? userAvatar; private bool switching; private bool closingAfterLeave; private bool leaving; + private bool syncBusy; public MainForm(ApiClient api) { @@ -31,7 +36,12 @@ public MainForm(ApiClient api) StartPosition = FormStartPosition.CenterScreen; BuildShell(); api.Unauthorized += OnUnauthorized; - Shown += async (_, _) => { if (AutoOpenFirstPage && current is null) await ShowPageAsync("today"); }; + syncTimer.Tick += async (_, _) => await UpdateSyncAsync(); + Shown += async (_, _) => + { + syncTimer.Start(); _ = UpdateSyncAsync(); + if (AutoOpenFirstPage && current is null) await ShowPageAsync("today"); + }; } public bool SignedOut { get; private set; } @@ -43,9 +53,10 @@ public MainForm(ApiClient api) private void BuildShell() { - var sidebar = new TableLayoutPanel { Dock = DockStyle.Left, Width = 250, BackColor = Theme.Sidebar, RowCount = 3, Padding = new Padding(16, 22, 16, 16) }; + var sidebar = new TableLayoutPanel { Dock = DockStyle.Left, Width = 250, BackColor = Theme.Sidebar, RowCount = 4, Padding = new Padding(16, 22, 16, 16) }; sidebar.RowStyles.Add(new RowStyle(SizeType.Absolute, 84)); sidebar.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + sidebar.RowStyles.Add(new RowStyle(SizeType.Absolute, 28)); sidebar.RowStyles.Add(new RowStyle(SizeType.Absolute, 64)); var brand = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 2, BackColor = Theme.Sidebar, Margin = Padding.Empty }; @@ -68,6 +79,10 @@ private void BuildShell() } sidebar.Controls.Add(menu, 0, 1); + syncStatus.AutoEllipsis = true; syncStatus.Cursor = Cursors.Hand; syncStatus.Visible = false; + syncStatus.Click += async (_, _) => await UpdateSyncAsync(run: true); + sidebar.Controls.Add(syncStatus, 0, 2); + var user = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 3, RowCount = 2, BackColor = Theme.Sidebar, Margin = Padding.Empty }; user.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 44)); user.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); user.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 44)); user.RowStyles.Add(new RowStyle(SizeType.Percent, 55)); user.RowStyles.Add(new RowStyle(SizeType.Percent, 45)); @@ -96,12 +111,59 @@ void OpenUserMenu(object? sender, EventArgs e) } avatar.Click += OpenUserMenu; nameLabel.Click += OpenUserMenu; roleLabel.Click += OpenUserMenu; user.Click += OpenUserMenu; - sidebar.Controls.Add(user, 0, 2); + sidebar.Controls.Add(user, 0, 3); Controls.Add(host); Controls.Add(sidebar); } + private async Task UpdateSyncAsync(bool run = false) + { + if (syncBusy || leaving || IsDisposed) return; + syncBusy = true; + if (run) { syncStatus.Text = "☁ Синхронизация…"; syncToolTip.SetToolTip(syncStatus, ""); } + try + { + var status = run ? await api.SyncNowAsync() : await api.SyncStatusAsync(); + if (leaving || IsDisposed) return; + if (!status.Enabled || status.State == "Disabled") + { + syncStatus.Visible = false; + syncToolTip.SetToolTip(syncStatus, ""); + return; + } + var queue = status.Pending > 0 ? $" · в очереди {status.Pending}" : ""; + syncStatus.Text = status.State switch + { + "Ok" => "☁ Синхронизировано" + queue, + "Offline" => "☁ Нет связи — работа офлайн" + queue, + "NotSignedIn" => "☁ Облако не подключено", + "Error" => "☁ Ошибка синхронизации", + _ => "" + }; + syncStatus.Visible = syncStatus.Text.Length > 0; + var details = status.LastSuccessAt is { } last + ? $"Последняя синхронизация: {last.ToLocalTime():g}" + : "Синхронизации ещё не было"; + if (!string.IsNullOrWhiteSpace(status.Error)) details += $"\nОшибка: {status.Error}"; + syncToolTip.SetToolTip(syncStatus, details); + } + catch (ApiException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + if (leaving || IsDisposed) return; + syncStatus.Visible = false; + syncToolTip.SetToolTip(syncStatus, ""); + } + catch (Exception ex) + { + if (leaving || IsDisposed) return; + syncStatus.Text = "☁ Нет связи с сервером"; + syncStatus.Visible = true; + syncToolTip.SetToolTip(syncStatus, ex.Message); + } + finally { syncBusy = false; } + } + private static string Initials(string name) => string.Concat(name.Split(' ', StringSplitOptions.RemoveEmptyEntries).Where(p => char.IsLetter(p[0])).Take(2).Select(p => char.ToUpperInvariant(p[0]))); @@ -184,6 +246,7 @@ private void OnUnauthorized() protected override async void OnFormClosing(FormClosingEventArgs e) { + syncTimer.Stop(); if (!closingAfterLeave && current is not null) { e.Cancel = true; @@ -198,6 +261,8 @@ protected override async void OnFormClosing(FormClosingEventArgs e) return; } api.Unauthorized -= OnUnauthorized; + syncTimer.Dispose(); + syncToolTip.Dispose(); userMenu.Dispose(); base.OnFormClosing(e); } diff --git a/firebase.json b/firebase.json new file mode 100644 index 0000000..a94b1ec --- /dev/null +++ b/firebase.json @@ -0,0 +1,15 @@ +{ + "firestore": { + "rules": "firestore.rules" + }, + "emulators": { + "auth": { + "port": 9099 + }, + "firestore": { + "port": 8080 + }, + "ui": { "enabled": false }, + "singleProjectMode": true + } +} diff --git a/firestore-debug.log b/firestore-debug.log new file mode 100644 index 0000000..225f4e2 --- /dev/null +++ b/firestore-debug.log @@ -0,0 +1,1224 @@ +Oct 20, 2026 11:06:52 PM com.google.cloud.datastore.emulator.firestore.websocket.WebSocketServer start +INFO: Started WebSocket server on ws://127.0.0.1:9150 +API endpoint: http://127.0.0.1:8080 +If you are using a library that supports the FIRESTORE_EMULATOR_HOST environment variable, run: + + export FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 + +If you are running a Firestore in Datastore Mode project, run: + + export DATASTORE_EMULATOR_HOST=127.0.0.1:8080 + +Note: Support for Datastore Mode is in preview. If you encounter any bugs please file at https://github.com/firebase/firebase-tools/issues. +Dev App Server is now running. + +Oct 20, 2026 11:07:01 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:01 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected HTTP/2 connection. +Oct 20, 2026 11:07:05 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:05 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/84rznab1FQHNqkhukwPLvIwTiNN7) not found. +com.google.cloud.datastore.core.exception.DatastoreException: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/84rznab1FQHNqkhukwPLvIwTiNN7) not found. + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:335) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:05 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: NOT_FOUND: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/84rznab1FQHNqkhukwPLvIwTiNN7) not found. +Oct 20, 2026 11:07:05 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. +com.google.cloud.datastore.core.exception.DatastoreException: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:335) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:05 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: NOT_FOUND: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. +Oct 20, 2026 11:07:07 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:08 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:09 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:09 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/jtC3QKM01PJPjjt0El0zfNDN6Q0L) not found. +com.google.cloud.datastore.core.exception.DatastoreException: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/jtC3QKM01PJPjjt0El0zfNDN6Q0L) not found. + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:335) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:09 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: NOT_FOUND: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/jtC3QKM01PJPjjt0El0zfNDN6Q0L) not found. +Oct 20, 2026 11:07:09 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. +com.google.cloud.datastore.core.exception.DatastoreException: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:335) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:09 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: NOT_FOUND: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. +Oct 20, 2026 11:07:10 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:12 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:13 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:13 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/QAR6TdsDqsUqrLQ66P5z6QC8GWWO) not found. +com.google.cloud.datastore.core.exception.DatastoreException: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/QAR6TdsDqsUqrLQ66P5z6QC8GWWO) not found. + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:335) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:13 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: NOT_FOUND: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/QAR6TdsDqsUqrLQ66P5z6QC8GWWO) not found. +Oct 20, 2026 11:07:13 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. +com.google.cloud.datastore.core.exception.DatastoreException: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:335) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:13 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: NOT_FOUND: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. +Oct 20, 2026 11:07:14 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:17 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:17 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:17 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:17 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/fF92lpIxncYvlxrYtDsqg6KEyBhm) not found. +com.google.cloud.datastore.core.exception.DatastoreException: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/fF92lpIxncYvlxrYtDsqg6KEyBhm) not found. + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:335) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:17 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: NOT_FOUND: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/fF92lpIxncYvlxrYtDsqg6KEyBhm) not found. +Oct 20, 2026 11:07:17 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. +com.google.cloud.datastore.core.exception.DatastoreException: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:335) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:17 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: NOT_FOUND: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. +Oct 20, 2026 11:07:18 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:20 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:20 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:21 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:21 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/C0sP3PtB6oqx8CFVWC0dtuljYRC6) not found. +com.google.cloud.datastore.core.exception.DatastoreException: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/C0sP3PtB6oqx8CFVWC0dtuljYRC6) not found. + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:335) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:21 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: NOT_FOUND: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/C0sP3PtB6oqx8CFVWC0dtuljYRC6) not found. +Oct 20, 2026 11:07:21 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. +com.google.cloud.datastore.core.exception.DatastoreException: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:335) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:21 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: NOT_FOUND: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. +Oct 20, 2026 11:07:21 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:21 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: +false for 'create' @ L33, evaluation error at L43:24 for 'create' @ L43, false for 'update' @ L33, evaluation error at L51:24 for 'update' @ L51, false for 'update' @ L33, Property ClaimProof is undefined on object. for 'update' @ L51 +com.google.cloud.datastore.core.exception.DatastoreException: +false for 'create' @ L33, evaluation error at L43:24 for 'create' @ L43, false for 'update' @ L33, evaluation error at L51:24 for 'update' @ L51, false for 'update' @ L33, Property ClaimProof is undefined on object. for 'update' @ L51 + at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:121) + at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:318) + at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:295) + at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114) + at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:360) + at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:314) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.commit(CloudFirestoreV1.java:528) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.commit(CloudFirestoreV1Router.java:175) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$commit$8(FirestoreV1GrpcAdapter.java:162) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.commit(FirestoreV1GrpcAdapter.java:162) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1234) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) +Caused by: com.google.firebase.rules.runtime.common.EvaluationException: Error: C:\Users\ligreus\Desktop\BiStudent-firebase\firestore.rules line [55], column [12]. Property ClaimProof is undefined on object. + at com.google.firebase.rules.runtime.impl.DefaultEvaluator$TransformEvaluationException.apply(DefaultEvaluator.java:275) + at com.google.firebase.rules.runtime.impl.DefaultEvaluator$TransformEvaluationException.apply(DefaultEvaluator.java:261) + at com.google.common.util.concurrent.AbstractCatchingFuture$AsyncCatchingFuture.doFallback(AbstractCatchingFuture.java:211) + at com.google.common.util.concurrent.AbstractCatchingFuture$AsyncCatchingFuture.doFallback(AbstractCatchingFuture.java:198) + at com.google.common.util.concurrent.AbstractCatchingFuture.run(AbstractCatchingFuture.java:140) + at com.google.common.util.concurrent.DirectExecutor.execute(DirectExecutor.java:32) + at com.google.common.util.concurrent.AbstractFuture.executeListener(AbstractFuture.java:1306) + at com.google.common.util.concurrent.AbstractFuture.addListener(AbstractFuture.java:767) + at com.google.common.util.concurrent.FluentFuture$TrustedFuture.addListener(FluentFuture.java:122) + at com.google.common.util.concurrent.AbstractCatchingFuture.createAsync(AbstractCatchingFuture.java:63) + at com.google.common.util.concurrent.Futures.catchingAsync(Futures.java:413) + at com.google.firebase.rules.runtime.impl.DefaultEvaluator.evaluate(DefaultEvaluator.java:177) + at com.google.cloud.datastore.emulator.impl.rules.EmulatorRuleClient$EmulatorRuleEvaluator.evaluate(EmulatorRuleClient.java:88) + at com.google.cloud.datastore.core.auth.rules.AsyncTwoPhaseRulesAuthorizer.lambda$fullEvaluate$12(AsyncTwoPhaseRulesAuthorizer.java:952) + at com.google.cloud.datastore.computation.Computation.tryCatch(Computation.java:416) + at com.google.cloud.datastore.core.auth.rules.AsyncTwoPhaseRulesAuthorizer.fullEvaluate(AsyncTwoPhaseRulesAuthorizer.java:951) + at com.google.cloud.datastore.core.auth.rules.AsyncTwoPhaseRulesAuthorizer$CommitAuthorizerImpl.lambda$checkWrite$1(AsyncTwoPhaseRulesAuthorizer.java:1192) + at com.google.cloud.datastore.computation.Computation.lambda$tryClose$27(Computation.java:685) + at com.google.cloud.datastore.computation.Computation.tryCatch(Computation.java:416) + at com.google.cloud.datastore.computation.Computation.tryFinally(Computation.java:652) + at com.google.cloud.datastore.computation.Computation.tryFinally(Computation.java:658) + at com.google.cloud.datastore.computation.Computation.tryClose(Computation.java:685) + at com.google.cloud.datastore.core.auth.rules.AsyncTwoPhaseRulesAuthorizer$CommitAuthorizerImpl.checkWrite(AsyncTwoPhaseRulesAuthorizer.java:1161) + at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.lambda$checkCommit$0(DefaultEmulatorRulesAuthorizer.java:124) + at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:290) + ... 25 more + +Oct 20, 2026 11:07:21 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: PERMISSION_DENIED: +false for 'create' @ L33, evaluation error at L43:24 for 'create' @ L43, false for 'update' @ L33, evaluation error at L51:24 for 'update' @ L51, false for 'update' @ L33, Property ClaimProof is undefined on object. for 'update' @ L51 +Oct 20, 2026 11:07:21 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: +false for 'create' @ L33, evaluation error at L43:24 for 'create' @ L43, false for 'update' @ L33, evaluation error at L51:24 for 'update' @ L51, false for 'update' @ L33, false for 'update' @ L51 +com.google.cloud.datastore.core.exception.DatastoreException: +false for 'create' @ L33, evaluation error at L43:24 for 'create' @ L43, false for 'update' @ L33, evaluation error at L51:24 for 'update' @ L51, false for 'update' @ L33, false for 'update' @ L51 + at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:121) + at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:318) + at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:295) + at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114) + at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:360) + at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:314) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.commit(CloudFirestoreV1.java:528) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.commit(CloudFirestoreV1Router.java:175) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$commit$8(FirestoreV1GrpcAdapter.java:162) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.commit(FirestoreV1GrpcAdapter.java:162) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1234) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:21 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: PERMISSION_DENIED: +false for 'create' @ L33, evaluation error at L43:24 for 'create' @ L43, false for 'update' @ L33, evaluation error at L51:24 for 'update' @ L51, false for 'update' @ L33, false for 'update' @ L51 +Oct 20, 2026 11:07:21 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: +false for 'create' @ L33, evaluation error at L73:24 for 'create' @ L73, false for 'update' @ L33, evaluation error at L78:32 for 'update' @ L78, false for 'create' @ L33, false for 'create' @ L73 +com.google.cloud.datastore.core.exception.DatastoreException: +false for 'create' @ L33, evaluation error at L73:24 for 'create' @ L73, false for 'update' @ L33, evaluation error at L78:32 for 'update' @ L78, false for 'create' @ L33, false for 'create' @ L73 + at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:121) + at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:318) + at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:295) + at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkCommit(DefaultEmulatorRulesAuthorizer.java:114) + at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.commitHelper(FirestoreEmulatorHelper.java:360) + at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.internalCommit(FirestoreEmulatorHelper.java:314) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.commit(CloudFirestoreV1.java:528) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.commit(CloudFirestoreV1Router.java:175) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$commit$8(FirestoreV1GrpcAdapter.java:162) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.commit(FirestoreV1GrpcAdapter.java:162) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1234) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:21 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: PERMISSION_DENIED: +false for 'create' @ L33, evaluation error at L73:24 for 'create' @ L73, false for 'update' @ L33, evaluation error at L78:32 for 'update' @ L78, false for 'create' @ L33, false for 'create' @ L73 +Oct 20, 2026 11:07:21 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:22 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:22 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/pHKpopbylz1e6UW6LzdQjgumjJM6) not found. +com.google.cloud.datastore.core.exception.DatastoreException: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/pHKpopbylz1e6UW6LzdQjgumjJM6) not found. + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:335) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:22 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: NOT_FOUND: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/pHKpopbylz1e6UW6LzdQjgumjJM6) not found. +Oct 20, 2026 11:07:22 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. +com.google.cloud.datastore.core.exception.DatastoreException: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:335) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:22 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: NOT_FOUND: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. +Oct 20, 2026 11:07:23 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:23 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/UCeeyja9cqInVfRKi6VgtShC5stx) not found. +com.google.cloud.datastore.core.exception.DatastoreException: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/UCeeyja9cqInVfRKi6VgtShC5stx) not found. + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:335) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:23 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: NOT_FOUND: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/UCeeyja9cqInVfRKi6VgtShC5stx) not found. +Oct 20, 2026 11:07:23 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:23 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:24 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:24 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/xV733otdDj4j1uvC8ZnVQGDycgqa) not found. +com.google.cloud.datastore.core.exception.DatastoreException: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/xV733otdDj4j1uvC8ZnVQGDycgqa) not found. + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:335) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:24 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: NOT_FOUND: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/xV733otdDj4j1uvC8ZnVQGDycgqa) not found. +Oct 20, 2026 11:07:24 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. +com.google.cloud.datastore.core.exception.DatastoreException: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:335) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:24 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: NOT_FOUND: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. +Oct 20, 2026 11:07:25 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:25 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: +evaluation error at L33:29 for 'get' @ L33, false for 'get' @ L33 +com.google.cloud.datastore.core.exception.DatastoreException: +evaluation error at L33:29 for 'get' @ L33, false for 'get' @ L33 + at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:121) + at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:318) + at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:295) + at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkLookup(DefaultEmulatorRulesAuthorizer.java:153) + at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.snapshotRead(FirestoreEmulatorHelper.java:286) + at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.lambda$read$0(FirestoreEmulatorHelper.java:245) + at com.google.cloud.datastore.emulator.impl.transactions.EmulatorTransactionManager$ReadOnlyTransaction.fold(EmulatorTransactionManager.java:461) + at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.read(FirestoreEmulatorHelper.java:244) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:331) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:25 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: PERMISSION_DENIED: +evaluation error at L33:29 for 'get' @ L33, false for 'get' @ L33 +Oct 20, 2026 11:07:25 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: Document (projects/demo-bistudent/databases/(default)/documents/Groups/anything) not found. +com.google.cloud.datastore.core.exception.DatastoreException: Document (projects/demo-bistudent/databases/(default)/documents/Groups/anything) not found. + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:335) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:25 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: NOT_FOUND: Document (projects/demo-bistudent/databases/(default)/documents/Groups/anything) not found. +Oct 20, 2026 11:07:25 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: +evaluation error at L33:29 for 'get' @ L33, false for 'get' @ L33 +com.google.cloud.datastore.core.exception.DatastoreException: +evaluation error at L33:29 for 'get' @ L33, false for 'get' @ L33 + at com.google.cloud.datastore.core.exception.DatastoreException$Builder.build(DatastoreException.java:121) + at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.verboseError(DefaultEmulatorRulesAuthorizer.java:318) + at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.withVerboseErrors(DefaultEmulatorRulesAuthorizer.java:295) + at com.google.cloud.datastore.emulator.impl.rules.DefaultEmulatorRulesAuthorizer.checkLookup(DefaultEmulatorRulesAuthorizer.java:153) + at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.snapshotRead(FirestoreEmulatorHelper.java:286) + at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.lambda$read$0(FirestoreEmulatorHelper.java:245) + at com.google.cloud.datastore.emulator.impl.transactions.EmulatorTransactionManager$ReadOnlyTransaction.fold(EmulatorTransactionManager.java:461) + at com.google.cloud.datastore.emulator.impl.firestore.FirestoreEmulatorHelper.read(FirestoreEmulatorHelper.java:244) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:331) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:25 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: PERMISSION_DENIED: +evaluation error at L33:29 for 'get' @ L33, false for 'get' @ L33 +Oct 20, 2026 11:07:25 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:26 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:26 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/IpAmud9cJ6L0J2ebvmtzfUsklnW2) not found. +com.google.cloud.datastore.core.exception.DatastoreException: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/IpAmud9cJ6L0J2ebvmtzfUsklnW2) not found. + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:335) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:26 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: NOT_FOUND: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/IpAmud9cJ6L0J2ebvmtzfUsklnW2) not found. +Oct 20, 2026 11:07:26 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. +com.google.cloud.datastore.core.exception.DatastoreException: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:335) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:26 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: NOT_FOUND: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. +Oct 20, 2026 11:07:26 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:26 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:27 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:27 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/ar61LUObvEorez9cLHOTsPeSmh3k) not found. +com.google.cloud.datastore.core.exception.DatastoreException: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/ar61LUObvEorez9cLHOTsPeSmh3k) not found. + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:335) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:27 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: NOT_FOUND: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/ar61LUObvEorez9cLHOTsPeSmh3k) not found. +Oct 20, 2026 11:07:27 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. +com.google.cloud.datastore.core.exception.DatastoreException: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:335) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:27 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: NOT_FOUND: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. +Oct 20, 2026 11:07:32 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:32 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:32 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/CgDLmVwWZsYy5fU07KMhcNvrzIGe) not found. +com.google.cloud.datastore.core.exception.DatastoreException: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/CgDLmVwWZsYy5fU07KMhcNvrzIGe) not found. + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:335) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:32 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: NOT_FOUND: Document (projects/demo-bistudent/databases/(default)/documents/TeacherUids/CgDLmVwWZsYy5fU07KMhcNvrzIGe) not found. +Oct 20, 2026 11:07:32 PM com.google.cloud.datastore.emulator.impl.util.WrappedStreamObserver onError +WARNING: Operation failed: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. +com.google.cloud.datastore.core.exception.DatastoreException: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1.getDocument(CloudFirestoreV1.java:335) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getDocument(CloudFirestoreV1Router.java:129) + at com.google.cloud.datastore.emulator.impl.firestore.CloudFirestoreV1Router.getOrListDocuments(CloudFirestoreV1Router.java:123) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.lambda$getOrListDocuments$0(FirestoreV1GrpcAdapter.java:108) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter.unary(FirestoreV1GrpcAdapter.java:72) + at com.google.cloud.datastore.emulator.firestore.v1.FirestoreV1GrpcAdapter$1.getOrListDocuments(FirestoreV1GrpcAdapter.java:108) + at com.google.firestore.v1.FirestoreGrpc$MethodHandlers.invoke(FirestoreGrpc.java:1206) + at io.grpc.stub.ServerCalls$UnaryServerCallHandler$UnaryServerCallListener.onHalfClose(ServerCalls.java:182) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.PartialForwardingServerCallListener.onHalfClose(PartialForwardingServerCallListener.java:35) + at io.grpc.ForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:23) + at io.grpc.ForwardingServerCallListener$SimpleForwardingServerCallListener.onHalfClose(ForwardingServerCallListener.java:40) + at io.grpc.Contexts$ContextualizedServerCallListener.onHalfClose(Contexts.java:86) + at io.grpc.internal.ServerCallImpl$ServerStreamListenerImpl.halfClosed(ServerCallImpl.java:356) + at io.grpc.internal.ServerImpl$JumpToApplicationThreadServerStreamListener$1HalfClosed.runInContext(ServerImpl.java:861) + at io.grpc.internal.ContextRunnable.run(ContextRunnable.java:37) + at io.grpc.internal.SerializingExecutor.run(SerializingExecutor.java:133) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:32 PM io.gapi.emulators.netty.HttpHandler$1 onError +INFO: Exception when handling request: NOT_FOUND: Document (projects/demo-bistudent/databases/(default)/documents/meta/bootstrap) not found. +Oct 20, 2026 11:07:33 PM io.gapi.emulators.netty.HttpVersionRoutingHandler channelRead +INFO: Detected non-HTTP/2 connection. +Oct 20, 2026 11:07:34 PM io.netty.channel.DefaultChannelPipeline onUnhandledInboundException +WARNING: An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception. +java.net.SocketException: Connection reset + at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(Unknown Source) + at java.base/sun.nio.ch.SocketChannelImpl.read(Unknown Source) + at io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:255) + at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132) + at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:356) + at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:151) + at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:788) + at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:724) + at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:650) + at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562) + at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:994) + at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) + at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:34 PM io.netty.channel.DefaultChannelPipeline onUnhandledInboundException +WARNING: An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception. +java.net.SocketException: Connection reset + at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(Unknown Source) + at java.base/sun.nio.ch.SocketChannelImpl.read(Unknown Source) + at io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:255) + at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132) + at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:356) + at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:151) + at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:788) + at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:724) + at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:650) + at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562) + at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:994) + at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) + at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:34 PM io.netty.channel.DefaultChannelPipeline onUnhandledInboundException +WARNING: An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception. +java.net.SocketException: Connection reset + at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(Unknown Source) + at java.base/sun.nio.ch.SocketChannelImpl.read(Unknown Source) + at io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:255) + at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132) + at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:356) + at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:151) + at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:788) + at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:724) + at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:650) + at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562) + at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:994) + at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) + at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:34 PM io.netty.channel.DefaultChannelPipeline onUnhandledInboundException +WARNING: An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception. +java.net.SocketException: Connection reset + at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(Unknown Source) + at java.base/sun.nio.ch.SocketChannelImpl.read(Unknown Source) + at io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:255) + at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132) + at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:356) + at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:151) + at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:788) + at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:724) + at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:650) + at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562) + at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:994) + at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) + at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:34 PM io.netty.channel.DefaultChannelPipeline onUnhandledInboundException +WARNING: An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception. +java.net.SocketException: Connection reset + at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(Unknown Source) + at java.base/sun.nio.ch.SocketChannelImpl.read(Unknown Source) + at io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:255) + at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132) + at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:356) + at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:151) + at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:788) + at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:724) + at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:650) + at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562) + at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:994) + at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) + at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:34 PM io.netty.channel.DefaultChannelPipeline onUnhandledInboundException +WARNING: An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception. +java.net.SocketException: Connection reset + at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(Unknown Source) + at java.base/sun.nio.ch.SocketChannelImpl.read(Unknown Source) + at io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:255) + at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132) + at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:356) + at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:151) + at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:788) + at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:724) + at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:650) + at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562) + at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:994) + at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) + at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:34 PM io.netty.channel.DefaultChannelPipeline onUnhandledInboundException +WARNING: An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception. +java.net.SocketException: Connection reset + at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(Unknown Source) + at java.base/sun.nio.ch.SocketChannelImpl.read(Unknown Source) + at io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:255) + at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132) + at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:356) + at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:151) + at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:788) + at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:724) + at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:650) + at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562) + at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:994) + at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) + at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:34 PM io.netty.channel.DefaultChannelPipeline onUnhandledInboundException +WARNING: An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception. +java.net.SocketException: Connection reset + at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(Unknown Source) + at java.base/sun.nio.ch.SocketChannelImpl.read(Unknown Source) + at io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:255) + at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132) + at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:356) + at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:151) + at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:788) + at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:724) + at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:650) + at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562) + at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:994) + at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) + at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:34 PM io.netty.channel.DefaultChannelPipeline onUnhandledInboundException +WARNING: An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception. +java.net.SocketException: Connection reset + at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(Unknown Source) + at java.base/sun.nio.ch.SocketChannelImpl.read(Unknown Source) + at io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:255) + at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132) + at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:356) + at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:151) + at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:788) + at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:724) + at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:650) + at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562) + at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:994) + at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) + at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:34 PM io.netty.channel.DefaultChannelPipeline onUnhandledInboundException +WARNING: An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception. +java.net.SocketException: Connection reset + at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(Unknown Source) + at java.base/sun.nio.ch.SocketChannelImpl.read(Unknown Source) + at io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:255) + at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132) + at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:356) + at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:151) + at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:788) + at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:724) + at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:650) + at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562) + at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:994) + at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) + at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:34 PM io.netty.channel.DefaultChannelPipeline onUnhandledInboundException +WARNING: An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception. +java.net.SocketException: Connection reset + at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(Unknown Source) + at java.base/sun.nio.ch.SocketChannelImpl.read(Unknown Source) + at io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:255) + at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132) + at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:356) + at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:151) + at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:788) + at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:724) + at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:650) + at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562) + at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:994) + at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) + at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:34 PM io.netty.channel.DefaultChannelPipeline onUnhandledInboundException +WARNING: An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception. +java.net.SocketException: Connection reset + at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(Unknown Source) + at java.base/sun.nio.ch.SocketChannelImpl.read(Unknown Source) + at io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:255) + at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132) + at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:356) + at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:151) + at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:788) + at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:724) + at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:650) + at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562) + at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:994) + at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) + at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:34 PM io.netty.channel.DefaultChannelPipeline onUnhandledInboundException +WARNING: An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception. +java.net.SocketException: Connection reset + at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(Unknown Source) + at java.base/sun.nio.ch.SocketChannelImpl.read(Unknown Source) + at io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:255) + at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132) + at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:356) + at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:151) + at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:788) + at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:724) + at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:650) + at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562) + at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:994) + at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) + at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:34 PM io.netty.channel.DefaultChannelPipeline onUnhandledInboundException +WARNING: An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception. +java.net.SocketException: Connection reset + at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(Unknown Source) + at java.base/sun.nio.ch.SocketChannelImpl.read(Unknown Source) + at io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:255) + at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132) + at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:356) + at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:151) + at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:788) + at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:724) + at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:650) + at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562) + at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:994) + at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) + at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:34 PM io.netty.channel.DefaultChannelPipeline onUnhandledInboundException +WARNING: An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception. +java.net.SocketException: Connection reset + at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(Unknown Source) + at java.base/sun.nio.ch.SocketChannelImpl.read(Unknown Source) + at io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:255) + at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132) + at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:356) + at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:151) + at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:788) + at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:724) + at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:650) + at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562) + at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:994) + at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) + at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) + at java.base/java.lang.Thread.run(Unknown Source) + +Oct 20, 2026 11:07:34 PM io.netty.channel.DefaultChannelPipeline onUnhandledInboundException +WARNING: An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception. +java.net.SocketException: Connection reset + at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(Unknown Source) + at java.base/sun.nio.ch.SocketChannelImpl.read(Unknown Source) + at io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:255) + at io.netty.buffer.AbstractByteBuf.writeBytes(AbstractByteBuf.java:1132) + at io.netty.channel.socket.nio.NioSocketChannel.doReadBytes(NioSocketChannel.java:356) + at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:151) + at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:788) + at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:724) + at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:650) + at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:562) + at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:994) + at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) + at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) + at java.base/java.lang.Thread.run(Unknown Source) + diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 0000000..ed78313 --- /dev/null +++ b/firestore.rules @@ -0,0 +1,89 @@ +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + function signedIn() { + return request.auth != null; + } + + function uidPath() { + return /databases/$(database)/documents/TeacherUids/$(request.auth.uid); + } + + function teacherPath() { + return /databases/$(database)/documents/Teachers/$(get(uidPath()).data.teacher); + } + + function teacherDoc() { + return get(teacherPath()); + } + + function teacher() { + return signedIn() + && exists(uidPath()) + && exists(teacherPath()) + && teacherDoc().data.IsActive == true + && teacherDoc().data._deleted != true; + } + + function admin() { + return teacher() && teacherDoc().data.IsAdmin == true; + } + + match /{collection}/{doc} { + allow read, write: if teacher() && collection in [ + 'Groups', 'Students', 'GroupMemberships', 'FaceSamples', + 'ScheduleSlots', 'SlotCancellations', 'AttendanceMarks', + 'LessonGrades', 'Assessments', 'AssessmentGrades', 'FinalGrades', + 'EnrollmentCodes', 'FaceSubmissions', 'FaceSubmissionImages' + ]; + } + + match /Teachers/{tid} { + allow read: if teacher(); + allow create: if admin() || ( + signedIn() + && !exists(/databases/$(database)/documents/meta/bootstrap) + && getAfter(/databases/$(database)/documents/meta/bootstrap).data.uid == request.auth.uid + && request.resource.data.FirebaseUid == request.auth.uid + && request.resource.data.IsAdmin == true + && request.resource.data.IsActive == true + ); + allow update: if admin() || ( + signedIn() + && resource.data.FirebaseUid == null + && resource.data.ClaimHash is bytes + && request.resource.data.ClaimProof is string + && hashing.sha256(request.resource.data.ClaimProof) + == resource.data.ClaimHash + && request.resource.data.FirebaseUid == request.auth.uid + && resource.data.Email == request.auth.token.email + && request.resource.data.diff(resource.data).affectedKeys() + .hasOnly(['FirebaseUid', 'ClaimProof', '_updatedAt']) + ) || ( + teacher() + && resource.data.FirebaseUid == request.auth.uid + && request.resource.data.diff(resource.data).affectedKeys() + .hasOnly(['MustChangePassword', '_updatedAt', '_device']) + ); + allow delete: if false; + } + + match /TeacherUids/{uid} { + allow read: if signedIn() && (request.auth.uid == uid || teacher()); + allow create: if signedIn() + && request.resource.data.keys().hasOnly(['teacher']) + && request.resource.data.teacher is string + && getAfter(/databases/$(database)/documents/Teachers/$(request.resource.data.teacher)).data.FirebaseUid == uid + && (request.auth.uid == uid || admin()); + allow update, delete: if admin(); + } + + match /meta/bootstrap { + allow read: if signedIn(); + allow create: if signedIn() + && request.resource.data.keys().hasOnly(['uid']) + && request.resource.data.uid == request.auth.uid; + allow update, delete: if false; + } + } +}