From ff430443aa706b0b1c2324d91797d8077227629f Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sun, 9 Aug 2026 00:20:13 -0700 Subject: [PATCH 01/38] feat: add Kindle-style reading time tracker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WordCountService: singleton that walks all SiteMappings at startup, strips
//
+            
+            """;
+        (string tempDir, string filePath) = WriteTempHtmlFile(html);
+        try
+        {
+            string relativePath = Path.GetFileName(filePath);
+            SiteMapping mapping = MakeMapping("page3", tempDir, relativePath, 1, 1);
+            (WordCountService service, _) = CreateService([mapping], tempDir);
+
+            int count = service.GetPageWordCount("page3");
+
+            // "Two words." = 2
+            await Assert.That(count).IsEqualTo(2);
+        }
+        finally
+        {
+            Directory.Delete(tempDir, recursive: true);
+        }
+    }
+
+    [Test]
+    public async Task GetPageWordCount_FileNotFound_ReturnsZero()
+    {
+        // Arrange — map a page that doesn't have a corresponding file
+        string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+        Directory.CreateDirectory(tempDir);
+        try
+        {
+            SiteMapping mapping = MakeMapping("missing", tempDir, "nonexistent.html", 1, 1);
+            (WordCountService service, _) = CreateService([mapping], tempDir);
+
+            int count = service.GetPageWordCount("missing");
+
+            await Assert.That(count).IsEqualTo(0);
+        }
+        finally
+        {
+            Directory.Delete(tempDir, recursive: true);
+        }
+    }
+
+    [Test]
+    public async Task GetChapterWordCount_SumsAllPagesInChapter()
+    {
+        string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+        Directory.CreateDirectory(tempDir);
+        try
+        {
+            // Page A: 3 words; Page B: 4 words → chapter total 7
+            File.WriteAllText(Path.Combine(tempDir, "a.html"), "

one two three

"); + File.WriteAllText(Path.Combine(tempDir, "b.html"), "

four five six seven

"); + + SiteMapping[] mappings = + [ + MakeMapping("a", tempDir, "a.html", 1, 1), + MakeMapping("b", tempDir, "b.html", 1, 2), + ]; + (WordCountService service, _) = CreateService(mappings, tempDir); + + await Assert.That(service.GetChapterWordCount(1)).IsEqualTo(7); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public async Task GetBookWordCount_SumsAllChapters() + { + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "ch1.html"), "

alpha beta

"); // 2 words + File.WriteAllText(Path.Combine(tempDir, "ch2.html"), "

gamma delta epsilon

"); // 3 words + + SiteMapping[] mappings = + [ + MakeMapping("ch1", tempDir, "ch1.html", 1, 1), + MakeMapping("ch2", tempDir, "ch2.html", 2, 1), + ]; + (WordCountService service, _) = CreateService(mappings, tempDir); + + await Assert.That(service.GetBookWordCount()).IsEqualTo(5); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Test] + public async Task GetWordsBeforePage_ReturnsCorrectPrefixSum() + { + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "p1.html"), "

one two

"); // 2 words + File.WriteAllText(Path.Combine(tempDir, "p2.html"), "

three

"); // 1 word + + SiteMapping[] mappings = + [ + MakeMapping("p1", tempDir, "p1.html", 1, 1), + MakeMapping("p2", tempDir, "p2.html", 1, 2), + ]; + (WordCountService service, _) = CreateService(mappings, tempDir); + + await Assert.That(service.GetWordsBeforePage("p1")).IsEqualTo(0); // first page + await Assert.That(service.GetWordsBeforePage("p2")).IsEqualTo(2); // 2 words from p1 + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } +} diff --git a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs index aaf81356..545fb2c4 100644 --- a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs +++ b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs @@ -11,9 +11,28 @@ public class EssentialCSharpWebContext(DbContextOptions DataProtectionKeys { get; set; } = null!; public DbSet McpApiTokens { get; set; } = null!; + public DbSet ReadingActivities { get; set; } = null!; + public DbSet UserReadingProfiles { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); + + builder.Entity(entity => + { + entity.HasKey(e => e.UserId); + entity.HasOne(e => e.User) + .WithOne() + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(entity => + { + entity.HasOne(e => e.User) + .WithMany() + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); } } diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml index f63c961f..549d2667 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml @@ -35,9 +35,54 @@ ViewData["ActivePage"] = ManageNavPages.Index; + +
+

Reading Speed

+

+ Essential C# tracks your reading speed to estimate how long chapters and the book will take you. + Resetting clears all recorded data and returns to the default estimate. +

+ + @section Scripts { + } diff --git a/EssentialCSharp.Web/Controllers/HomeController.cs b/EssentialCSharp.Web/Controllers/HomeController.cs index 1437d956..ea1b8522 100644 --- a/EssentialCSharp.Web/Controllers/HomeController.cs +++ b/EssentialCSharp.Web/Controllers/HomeController.cs @@ -13,7 +13,7 @@ namespace EssentialCSharp.Web.Controllers; -public class HomeController(ILogger logger, IWebHostEnvironment hostingEnvironment, ISiteMappingService siteMappingService, ChapterDescriptionService chapterDescriptionService, IHttpContextAccessor httpContextAccessor, IRouteConfigurationService routeConfigurationService, IOptions siteSettings) : BaseController(routeConfigurationService, httpContextAccessor) +public class HomeController(ILogger logger, IWebHostEnvironment hostingEnvironment, ISiteMappingService siteMappingService, ChapterDescriptionService chapterDescriptionService, IHttpContextAccessor httpContextAccessor, IRouteConfigurationService routeConfigurationService, IOptions siteSettings, IWordCountService wordCountService) : BaseController(routeConfigurationService, httpContextAccessor) { [EnableRateLimiting("content")] public IActionResult Index() @@ -69,6 +69,11 @@ public IActionResult Index() ViewBag.HeadContents = headHtml; ViewBag.Contents = html; ViewBag.NeedsPageHeading = doc.DocumentNode.SelectSingleNode("//body//h1") is null; + ViewBag.PageWordCount = wordCountService.GetPageWordCount(siteMapping.PrimaryKey); + ViewBag.ChapterWordCount = wordCountService.GetChapterWordCount(siteMapping.ChapterNumber); + ViewBag.WordsBeforePage = wordCountService.GetWordsBeforePage(siteMapping.PrimaryKey); + ViewBag.ChapterStartWords = wordCountService.GetChapterStartWords(siteMapping.PrimaryKey); + ViewBag.BookWordCount = wordCountService.GetBookWordCount(); return View(); } else diff --git a/EssentialCSharp.Web/Controllers/ReadingController.cs b/EssentialCSharp.Web/Controllers/ReadingController.cs new file mode 100644 index 00000000..00719944 --- /dev/null +++ b/EssentialCSharp.Web/Controllers/ReadingController.cs @@ -0,0 +1,253 @@ +using System.Security.Claims; +using EssentialCSharp.Web.Data; +using EssentialCSharp.Web.Models; +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace EssentialCSharp.Web.Controllers; + +[ApiController] +[Route("api/reading")] +public partial class ReadingController( + EssentialCSharpWebContext context, + IWordCountService wordCountService, + ILogger logger) : ControllerBase +{ + // Algorithm constants (mirrors Kindle's ReadingTimer values). + private const int MaxWpmHardCutoff = 900; + private const double SlowOutlierFactor = 0.25; + private const int MaxReadingActivityRowsPerUser = 500; + + // High-performance logger messages (CA1848). + [LoggerMessage(Level = LogLevel.Debug, Message = "Discarding interval for {PageKey}: {Wpm:F0} WPM exceeds hard cutoff of {Cutoff}")] + private static partial void LogIntervalDiscarded(ILogger logger, string pageKey, double wpm, int cutoff); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Clamping slow interval for {PageKey}: {Wpm:F0} WPM → effective {EffSeconds}s")] + private static partial void LogIntervalClamped(ILogger logger, string pageKey, double wpm, int effSeconds); + + // ------------------------------------------------------------------------- + // GET /api/reading/book-stats (public — used by anonymous clients too) + // ------------------------------------------------------------------------- + + [HttpGet("book-stats")] + public IActionResult GetBookStats() + { + var chapters = wordCountService.GetChapterWordCounts() + .Select(c => new { chapterNumber = c.ChapterNumber, wordCount = c.WordCount }); + + return Ok(new + { + totalWordCount = wordCountService.GetBookWordCount(), + chapters + }); + } + + // ------------------------------------------------------------------------- + // GET /api/reading/profile (authenticated) + // ------------------------------------------------------------------------- + + [HttpGet("profile")] + [Authorize] + public async Task GetProfile(CancellationToken cancellationToken) + { + string userId = GetUserId(); + + UserReadingProfile? profile = await context.UserReadingProfiles + .FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken); + + if (profile is null) + { + return Ok(new { totalWordsRead = 0L, totalActiveSeconds = 0L, wpm = (double?)null }); + } + + return Ok(new + { + totalWordsRead = profile.TotalWordsRead, + totalActiveSeconds = profile.TotalActiveSeconds, + wpm = profile.DeriveWpm() + }); + } + + // ------------------------------------------------------------------------- + // POST /api/reading/session (authenticated) + // ------------------------------------------------------------------------- + + public record ReadingIntervalDto( + string PageKey, + int ActiveSeconds, + int WordsRead, + bool Completed); + + [HttpPost("session")] + [Authorize] + public async Task PostSession( + [FromBody] IEnumerable intervals, + CancellationToken cancellationToken) + { + string userId = GetUserId(); + + if (intervals is null) + { + return BadRequest("intervals is required"); + } + + List intervalList = intervals.ToList(); + if (intervalList.Count == 0) + { + return Ok(); + } + + // Load or create profile (used as reference WPM for outlier clamping). + UserReadingProfile? profile = await context.UserReadingProfiles + .FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken); + + double referenceWpm = profile?.DeriveWpm() ?? 0; + + long deltaWords = 0; + long deltaSeconds = 0; + var activities = new List(intervalList.Count); + + foreach (ReadingIntervalDto interval in intervalList) + { + if (interval.ActiveSeconds <= 0 || interval.WordsRead <= 0) + { + continue; + } + + double intervalWpm = interval.WordsRead / (interval.ActiveSeconds / 60.0); + + // Hard discard: too fast to be genuine reading. + if (intervalWpm > MaxWpmHardCutoff) + { + LogIntervalDiscarded(logger, interval.PageKey, intervalWpm, MaxWpmHardCutoff); + continue; + } + + // Soft clamp: interval is implausibly slow relative to reader's own rate. + int effectiveWords = interval.WordsRead; + int effectiveSeconds = interval.ActiveSeconds; + if (referenceWpm > 0 && intervalWpm < SlowOutlierFactor * referenceWpm) + { + // Clamp: keep the words, shrink the time so effective WPM = 0.25 × referenceWpm. + effectiveSeconds = (int)Math.Round(effectiveWords / (SlowOutlierFactor * referenceWpm) * 60.0); + LogIntervalClamped(logger, interval.PageKey, intervalWpm, effectiveSeconds); + } + + deltaWords += effectiveWords; + deltaSeconds += effectiveSeconds; + + activities.Add(new ReadingActivity + { + UserId = userId, + PageKey = interval.PageKey, + RecordedAtUtc = DateTime.UtcNow, + ActiveSeconds = interval.ActiveSeconds, + WordsRead = interval.WordsRead, + Completed = interval.Completed + }); + } + + // Persist inside a single transaction: insert detail rows, update aggregate, trim retention. + await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken); + try + { + if (activities.Count > 0) + { + await context.ReadingActivities.AddRangeAsync(activities, cancellationToken); + } + + if (deltaWords > 0 || deltaSeconds > 0) + { + if (profile is null) + { + profile = new UserReadingProfile + { + UserId = userId, + TotalWordsRead = deltaWords, + TotalActiveSeconds = deltaSeconds, + UpdatedAtUtc = DateTime.UtcNow + }; + context.UserReadingProfiles.Add(profile); + } + else + { + profile.TotalWordsRead += deltaWords; + profile.TotalActiveSeconds += deltaSeconds; + profile.UpdatedAtUtc = DateTime.UtcNow; + } + } + + await context.SaveChangesAsync(cancellationToken); + + // Trim ReadingActivity to the newest 500 rows for this user. + // We do this after SaveChanges so the new rows are visible in the sub-query. + await context.Database.ExecuteSqlRawAsync( + """ + DELETE FROM [ReadingActivities] + WHERE [UserId] = {0} + AND [Id] NOT IN ( + SELECT TOP ({1}) [Id] + FROM [ReadingActivities] + WHERE [UserId] = {0} + ORDER BY [RecordedAtUtc] DESC + ) + """, + [userId, MaxReadingActivityRowsPerUser], + cancellationToken); + + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + + return Ok(new + { + totalWordsRead = profile?.TotalWordsRead ?? 0, + totalActiveSeconds = profile?.TotalActiveSeconds ?? 0, + wpm = profile?.DeriveWpm() + }); + } + + // ------------------------------------------------------------------------- + // POST /api/reading/reset (authenticated) + // ------------------------------------------------------------------------- + + [HttpPost("reset")] + [Authorize] + public async Task Reset(CancellationToken cancellationToken) + { + string userId = GetUserId(); + + await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken); + try + { + await context.ReadingActivities + .Where(a => a.UserId == userId) + .ExecuteDeleteAsync(cancellationToken); + + await context.UserReadingProfiles + .Where(p => p.UserId == userId) + .ExecuteDeleteAsync(cancellationToken); + + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + + return Ok(); + } + + // ------------------------------------------------------------------------- + + private string GetUserId() => + User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? throw new InvalidOperationException("Authenticated user has no NameIdentifier claim."); +} diff --git a/EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.Designer.cs b/EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.Designer.cs new file mode 100644 index 00000000..be4d7218 --- /dev/null +++ b/EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.Designer.cs @@ -0,0 +1,448 @@ +// +using System; +using EssentialCSharp.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EssentialCSharp.Web.Migrations +{ + [DbContext(typeof(EssentialCSharpWebContext))] + [Migration("20260809064123_AddReadingTimeTracking")] + partial class AddReadingTimeTracking + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("ReferralCount") + .HasColumnType("int"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EssentialCSharp.Web.Models.McpApiToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varbinary(32)"); + + b.Property("UsageCount") + .HasColumnType("bigint"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("McpApiTokens"); + }); + + modelBuilder.Entity("EssentialCSharp.Web.Models.ReadingActivity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ActiveSeconds") + .HasColumnType("int"); + + b.Property("Completed") + .HasColumnType("bit"); + + b.Property("PageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RecordedAtUtc") + .HasColumnType("datetime2"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("WordsRead") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PageKey"); + + b.HasIndex("UserId", "RecordedAtUtc"); + + b.ToTable("ReadingActivities"); + }); + + modelBuilder.Entity("EssentialCSharp.Web.Models.UserReadingProfile", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("TotalActiveSeconds") + .HasColumnType("bigint"); + + b.Property("TotalWordsRead") + .HasColumnType("bigint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("UserId"); + + b.ToTable("UserReadingProfiles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EssentialCSharp.Web.Models.McpApiToken", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EssentialCSharp.Web.Models.ReadingActivity", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EssentialCSharp.Web.Models.UserReadingProfile", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", "User") + .WithOne() + .HasForeignKey("EssentialCSharp.Web.Models.UserReadingProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.cs b/EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.cs new file mode 100644 index 00000000..48686a89 --- /dev/null +++ b/EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.cs @@ -0,0 +1,80 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable +#pragma warning disable CA1861 + +namespace EssentialCSharp.Web.Migrations +{ + /// + public partial class AddReadingTimeTracking : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ReadingActivities", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + PageKey = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + RecordedAtUtc = table.Column(type: "datetime2", nullable: false), + ActiveSeconds = table.Column(type: "int", nullable: false), + WordsRead = table.Column(type: "int", nullable: false), + Completed = table.Column(type: "bit", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ReadingActivities", x => x.Id); + table.ForeignKey( + name: "FK_ReadingActivities_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserReadingProfiles", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + TotalWordsRead = table.Column(type: "bigint", nullable: false), + TotalActiveSeconds = table.Column(type: "bigint", nullable: false), + UpdatedAtUtc = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserReadingProfiles", x => x.UserId); + table.ForeignKey( + name: "FK_UserReadingProfiles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ReadingActivities_UserId_PageKey", + table: "ReadingActivities", + columns: new[] { "UserId", "PageKey" }); + + migrationBuilder.CreateIndex( + name: "IX_ReadingActivities_UserId_RecordedAtUtc", + table: "ReadingActivities", + columns: new[] { "UserId", "RecordedAtUtc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ReadingActivities"); + + migrationBuilder.DropTable( + name: "UserReadingProfiles"); + } + } +} diff --git a/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs b/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs index a7f7c2b2..2d6c64af 100644 --- a/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs +++ b/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("ProductVersion", "10.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -141,6 +141,65 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("McpApiTokens"); }); + modelBuilder.Entity("EssentialCSharp.Web.Models.ReadingActivity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ActiveSeconds") + .HasColumnType("int"); + + b.Property("Completed") + .HasColumnType("bit"); + + b.Property("PageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RecordedAtUtc") + .HasColumnType("datetime2"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("WordsRead") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PageKey"); + + b.HasIndex("UserId", "RecordedAtUtc"); + + b.ToTable("ReadingActivities"); + }); + + modelBuilder.Entity("EssentialCSharp.Web.Models.UserReadingProfile", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("TotalActiveSeconds") + .HasColumnType("bigint"); + + b.Property("TotalWordsRead") + .HasColumnType("bigint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("UserId"); + + b.ToTable("UserReadingProfiles"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => { b.Property("Id") @@ -308,6 +367,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("EssentialCSharp.Web.Models.ReadingActivity", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EssentialCSharp.Web.Models.UserReadingProfile", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", "User") + .WithOne() + .HasForeignKey("EssentialCSharp.Web.Models.UserReadingProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) diff --git a/EssentialCSharp.Web/Models/ReadingActivity.cs b/EssentialCSharp.Web/Models/ReadingActivity.cs new file mode 100644 index 00000000..6c02fa2b --- /dev/null +++ b/EssentialCSharp.Web/Models/ReadingActivity.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; +using EssentialCSharp.Web.Areas.Identity.Data; +using Microsoft.EntityFrameworkCore; + +namespace EssentialCSharp.Web.Models; + +/// +/// Records a single reading interval for a content page. +/// This is an audit/detail trail; the WPM math reads only +/// . +/// +[Index(nameof(UserId), nameof(RecordedAtUtc))] +[Index(nameof(UserId), nameof(PageKey))] +public class ReadingActivity +{ + public int Id { get; set; } + + [Required] + [MaxLength(450)] + public required string UserId { get; set; } + + /// + /// Matches for the page that was read. + /// + [Required] + [MaxLength(256)] + public required string PageKey { get; set; } + + public DateTime RecordedAtUtc { get; set; } = DateTime.UtcNow; + + /// Active (non-idle, non-hidden) seconds spent on this page. + public int ActiveSeconds { get; set; } + + /// Prose words credited to this reading interval (may be fractional for partial reads, rounded to int). + public int WordsRead { get; set; } + + /// True when the reader reached the bottom of the page content. + public bool Completed { get; set; } + + public EssentialCSharpWebUser? User { get; set; } +} diff --git a/EssentialCSharp.Web/Models/UserReadingProfile.cs b/EssentialCSharp.Web/Models/UserReadingProfile.cs new file mode 100644 index 00000000..298d4021 --- /dev/null +++ b/EssentialCSharp.Web/Models/UserReadingProfile.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; +using EssentialCSharp.Web.Areas.Identity.Data; + +namespace EssentialCSharp.Web.Models; + +/// +/// Aggregate reading profile for a single user. +/// WPM is always derived as TotalWordsRead / (TotalActiveSeconds / 60.0); +/// it is never stored directly so there is no floating-point drift over time. +/// +public class UserReadingProfile +{ + /// Primary key — also the FK to . + [Required] + [MaxLength(450)] + public required string UserId { get; set; } + + /// + /// Cumulative prose words credited across all accepted, clamped reading intervals. + /// + public long TotalWordsRead { get; set; } + + /// + /// Cumulative active seconds across all accepted, clamped reading intervals. + /// + public long TotalActiveSeconds { get; set; } + + public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; + + public EssentialCSharpWebUser? User { get; set; } + + /// + /// Derives the current words-per-minute estimate. Returns null if insufficient data. + /// + public double? DeriveWpm() => + TotalActiveSeconds > 0 ? TotalWordsRead / (TotalActiveSeconds / 60.0) : null; +} diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 1cb4de2a..668e667e 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -271,6 +271,7 @@ private static void Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddScoped(); // Add AI Chat services using configuration-driven backend selection. diff --git a/EssentialCSharp.Web/Services/IWordCountService.cs b/EssentialCSharp.Web/Services/IWordCountService.cs new file mode 100644 index 00000000..92567a5e --- /dev/null +++ b/EssentialCSharp.Web/Services/IWordCountService.cs @@ -0,0 +1,35 @@ +namespace EssentialCSharp.Web.Services; + +/// +/// Provides pre-computed prose word counts for content pages, enabling +/// Kindle-style reading time estimates. +/// +public interface IWordCountService +{ + /// Gets the prose word count for a specific page key. + int GetPageWordCount(string pageKey); + + /// Gets the total prose word count for a chapter. + int GetChapterWordCount(int chapterNumber); + + /// Gets the total prose word count for the entire book. + int GetBookWordCount(); + + /// + /// Gets the number of prose words before the given page (across the entire book). + /// Used for "words remaining in book" math. + /// + int GetWordsBeforePage(string pageKey); + + /// + /// Gets the number of prose words before the given page within its chapter. + /// Used for "words remaining in chapter" math. + /// + int GetChapterStartWords(string pageKey); + + /// Gets per-chapter word count summary for the book-stats API response. + IReadOnlyList GetChapterWordCounts(); +} + +/// Per-chapter word count summary returned by the book-stats API. +public record ChapterWordCount(int ChapterNumber, int WordCount); diff --git a/EssentialCSharp.Web/Services/WordCountService.cs b/EssentialCSharp.Web/Services/WordCountService.cs new file mode 100644 index 00000000..c951a53b --- /dev/null +++ b/EssentialCSharp.Web/Services/WordCountService.cs @@ -0,0 +1,154 @@ +using HtmlAgilityPack; + +namespace EssentialCSharp.Web.Services; + +/// +/// Singleton service that computes prose-only word counts for all content pages +/// at startup and caches them in memory. Code blocks (<pre>, +/// <code>, <script>, <style>) are excluded +/// because they are read very differently from prose and would skew WPM estimates. +/// +public class WordCountService : IWordCountService +{ + // Tags whose text content is excluded from prose word counts. + private static readonly HashSet ExcludedTags = new(StringComparer.OrdinalIgnoreCase) + { + "pre", "code", "script", "style" + }; + + private readonly Dictionary _pageWordCounts; + private readonly Dictionary _chapterWordCounts; + private readonly int _bookWordCount; + private readonly Dictionary _wordsBeforePage; + private readonly Dictionary _chapterStartWords; + private readonly IReadOnlyList _chapterWordCountList; + + public WordCountService(ISiteMappingService siteMappingService, IWebHostEnvironment hostingEnvironment) + { + _pageWordCounts = []; + _chapterWordCounts = []; + _wordsBeforePage = []; + _chapterStartWords = []; + + // Walk mappings in canonical reading order: chapter → page → order-on-page. + IEnumerable orderedMappings = siteMappingService.SiteMappings + .OrderBy(m => m.ChapterNumber) + .ThenBy(m => m.PageNumber) + .ThenBy(m => m.OrderOnPage); + + int bookWords = 0; + int currentChapter = -1; + int chapterWords = 0; + int chapterBookOffset = 0; // words before first page of current chapter in book + + foreach (SiteMapping mapping in orderedMappings) + { + string pageKey = mapping.PrimaryKey; + if (_pageWordCounts.ContainsKey(pageKey)) + { + // Multiple anchors on the same page; skip duplicates (already counted). + continue; + } + + // Chapter boundary: flush previous chapter totals. + if (mapping.ChapterNumber != currentChapter) + { + if (currentChapter >= 0) + { + _chapterWordCounts[currentChapter] = chapterWords; + } + currentChapter = mapping.ChapterNumber; + chapterBookOffset = bookWords; + chapterWords = 0; + } + + int words = CountProseWords(hostingEnvironment.ContentRootPath, mapping.PagePath); + _pageWordCounts[pageKey] = words; + _wordsBeforePage[pageKey] = bookWords; + _chapterStartWords[pageKey] = chapterWords; + + bookWords += words; + chapterWords += words; + } + + // Flush the last chapter. + if (currentChapter >= 0) + { + _chapterWordCounts[currentChapter] = chapterWords; + } + + _bookWordCount = bookWords; + _chapterWordCountList = _chapterWordCounts + .OrderBy(kv => kv.Key) + .Select(kv => new ChapterWordCount(kv.Key, kv.Value)) + .ToList() + .AsReadOnly(); + } + + public int GetPageWordCount(string pageKey) => + _pageWordCounts.TryGetValue(pageKey, out int count) ? count : 0; + + public int GetChapterWordCount(int chapterNumber) => + _chapterWordCounts.TryGetValue(chapterNumber, out int count) ? count : 0; + + public int GetBookWordCount() => _bookWordCount; + + public int GetWordsBeforePage(string pageKey) => + _wordsBeforePage.TryGetValue(pageKey, out int count) ? count : 0; + + public int GetChapterStartWords(string pageKey) => + _chapterStartWords.TryGetValue(pageKey, out int count) ? count : 0; + + public IReadOnlyList GetChapterWordCounts() => _chapterWordCountList; + + // --- + + private static int CountProseWords(string contentRoot, string[] pagePath) + { + string filePath = Path.Join(contentRoot, Path.Join(pagePath)); + if (!File.Exists(filePath)) + { + return 0; + } + + HtmlDocument doc = new(); + doc.Load(filePath); + + HtmlNode? body = doc.DocumentNode.SelectSingleNode("//body") ?? doc.DocumentNode; + + // Remove excluded tags (modifies in-place on a clone isn't available, so remove from live tree). + // XPath: //pre | //code | //script | //style + string xpath = string.Join(" | ", ExcludedTags.Select(t => $"//{t}")); + foreach (HtmlNode node in body.SelectNodes(xpath)?.ToList() ?? []) + { + node.Remove(); + } + + string text = body.InnerText; + return CountWords(text); + } + + private static int CountWords(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return 0; + } + + int count = 0; + bool inWord = false; + foreach (char c in text) + { + if (char.IsWhiteSpace(c)) + { + inWord = false; + } + else if (!inWord) + { + inWord = true; + count++; + } + } + return count; + } +} diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml index a855c9b4..00b71da2 100644 --- a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml +++ b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml @@ -221,6 +221,12 @@ window.BUILD_LABEL = @Json.Serialize(buildLabel); window.ENABLE_CHAT_WIDGET = @Json.Serialize(!Context.Request.Path.StartsWithSegments("/Identity")); window.HCAPTCHA_SITE_KEY = @Json.Serialize(chatCaptchaSiteKey); + window.CURRENT_PAGE_KEY = @Json.Serialize(ViewBag.CurrentPageKey); + window.PAGE_WORD_COUNT = @Json.Serialize(ViewBag.PageWordCount ?? 0); + window.CHAPTER_WORD_COUNT = @Json.Serialize(ViewBag.ChapterWordCount ?? 0); + window.WORDS_BEFORE_PAGE = @Json.Serialize(ViewBag.WordsBeforePage ?? 0); + window.CHAPTER_START_WORDS = @Json.Serialize(ViewBag.ChapterStartWords ?? 0); + window.BOOK_WORD_COUNT = @Json.Serialize(ViewBag.BookWordCount ?? 0); diff --git a/EssentialCSharp.Web/src/components/HeaderStatus.vue b/EssentialCSharp.Web/src/components/HeaderStatus.vue index 8f837b13..768ca491 100644 --- a/EssentialCSharp.Web/src/components/HeaderStatus.vue +++ b/EssentialCSharp.Web/src/components/HeaderStatus.vue @@ -1,5 +1,6 @@ + + + + diff --git a/EssentialCSharp.Web/src/composables/useReadingTracker.js b/EssentialCSharp.Web/src/composables/useReadingTracker.js new file mode 100644 index 00000000..1e143b89 --- /dev/null +++ b/EssentialCSharp.Web/src/composables/useReadingTracker.js @@ -0,0 +1,295 @@ +/** + * useReadingTracker — Kindle-style active-reading time tracker. + * + * State machine: READING → IDLE (after IDLE_THRESHOLD s of no input) + * → HIDDEN (tab not visible) + * Only accumulates activeSeconds while in READING state. + * + * Persists a rolling aggregate to: + * - Server (POST /api/reading/session) when authenticated. + * - localStorage key "readingProfile" for anonymous users and as a backup. + * + * On first authenticated page load, uploads the localStorage aggregate to the + * server and clears local storage (one-time sync after login). + */ + +import { ref, onMounted, onBeforeUnmount, readonly } from "vue"; + +// ----- Constants ----- +const IDLE_THRESHOLD_S = 300; // 5 minutes +const TICK_INTERVAL_MS = 1000; // 1 second timer +const DEFAULT_WPM = 200; // cold-start display default +const MOUSEMOVE_THROTTLE_MS = 1000; +const LOCAL_STORAGE_KEY = "readingProfile"; + +// ----- States ----- +const STATE_READING = "READING"; +const STATE_IDLE = "IDLE"; +const STATE_HIDDEN = "HIDDEN"; + +// ----- localStorage helpers ----- + +function loadLocalProfile() { + try { + const raw = localStorage.getItem(LOCAL_STORAGE_KEY); + if (!raw) return { totalWords: 0, totalActiveSeconds: 0 }; + return JSON.parse(raw); + } catch { + return { totalWords: 0, totalActiveSeconds: 0 }; + } +} + +function saveLocalProfile(profile) { + try { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(profile)); + } catch { + // Ignore storage errors (private browsing quota exceeded, etc.) + } +} + +function clearLocalProfile() { + try { + localStorage.removeItem(LOCAL_STORAGE_KEY); + } catch { + // Ignore + } +} + +// ----- API helpers ----- + +async function fetchServerProfile() { + try { + const res = await fetch("/api/reading/profile"); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + +async function postSession(intervals) { + if (!intervals || intervals.length === 0) return null; + try { + const res = await fetch("/api/reading/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(intervals) + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + +// ----- Composable ----- + +export function useReadingTracker() { + // Reactive WPM shown in the UI. + const wpm = ref(DEFAULT_WPM); + const activeSeconds = ref(0); + + // Reading state machine. + let state = STATE_READING; + let lastActivityAt = Date.now(); + let tickIntervalId = null; + let lastMousemoveAt = 0; + + // Scroll tracking. + let maxScrollFraction = 0; + + // Page context (from window globals set by _Layout.cshtml). + const pageKey = window.CURRENT_PAGE_KEY ?? null; + const pageWordCount = window.PAGE_WORD_COUNT ?? 0; + const isAuthenticated = Boolean(window.IS_AUTHENTICATED); + + // In-session server profile for reference WPM (loaded on mount). + let serverProfile = null; + + // ----- Scroll fraction ----- + + function getScrollFraction() { + const main = document.querySelector("main") ?? document.documentElement; + const scrollTop = window.scrollY || document.documentElement.scrollTop; + const scrollable = main.scrollHeight - main.clientHeight; + if (scrollable <= 0) return 1; + return Math.min(1, Math.max(0, scrollTop / scrollable)); + } + + // ----- State machine ----- + + function markActivity() { + lastActivityAt = Date.now(); + if (state === STATE_IDLE || state === STATE_HIDDEN) { + state = STATE_READING; + } + } + + function onVisibilityChange() { + if (document.visibilityState !== "visible") { + state = STATE_HIDDEN; + } else { + // Tab became visible — READING if recently active, else IDLE. + const idleDuration = (Date.now() - lastActivityAt) / 1000; + state = idleDuration <= IDLE_THRESHOLD_S ? STATE_READING : STATE_IDLE; + } + } + + function onMousemove() { + const now = Date.now(); + if (now - lastMousemoveAt >= MOUSEMOVE_THROTTLE_MS) { + lastMousemoveAt = now; + markActivity(); + } + } + + function onScroll() { + markActivity(); + const fraction = getScrollFraction(); + if (fraction > maxScrollFraction) { + maxScrollFraction = fraction; + } + } + + // ----- Tick ----- + + function tick() { + const now = Date.now(); + const idleDuration = (now - lastActivityAt) / 1000; + + if (state === STATE_READING && idleDuration > IDLE_THRESHOLD_S) { + state = STATE_IDLE; + } + + if (state === STATE_READING) { + activeSeconds.value++; + } + } + + // ----- Flush (send data on navigation away / unmount) ----- + + async function flush() { + if (!pageKey || activeSeconds.value <= 0 || pageWordCount <= 0) return; + + const wordsRead = Math.round(pageWordCount * maxScrollFraction); + const scrollFraction = maxScrollFraction; + // Near-bottom (within 5%) = completed. + const completed = scrollFraction >= 0.95; + + const interval = { + pageKey, + activeSeconds: activeSeconds.value, + wordsRead, + completed + }; + + if (isAuthenticated) { + const updated = await postSession([interval]); + if (updated) { + serverProfile = updated; + if (updated.wpm) { + wpm.value = updated.wpm; + } + } + } else { + // Update localStorage profile. + const local = loadLocalProfile(); + local.totalWords = (local.totalWords || 0) + wordsRead; + local.totalActiveSeconds = (local.totalActiveSeconds || 0) + activeSeconds.value; + saveLocalProfile(local); + + const localWpm = local.totalActiveSeconds > 0 + ? local.totalWords / (local.totalActiveSeconds / 60) + : 0; + if (localWpm > 0) { + wpm.value = localWpm; + } + } + } + + // ----- One-time sync: localStorage → server on first authenticated load ----- + + async function syncLocalToServer() { + if (!isAuthenticated) return; + + const local = loadLocalProfile(); + if (!local || (local.totalWords === 0 && local.totalActiveSeconds === 0)) return; + + // Only upload if server has no data yet. + const profile = await fetchServerProfile(); + if (profile && (profile.totalWordsRead > 0 || profile.totalActiveSeconds > 0)) { + // Server already has data — just clear local. + clearLocalProfile(); + return; + } + + // Upload local aggregate as a synthetic interval. + if (local.totalWords > 0 && local.totalActiveSeconds > 0) { + await postSession([{ + pageKey: "__localStorage_sync__", + activeSeconds: local.totalActiveSeconds, + wordsRead: local.totalWords, + completed: false + }]); + } + clearLocalProfile(); + } + + // ----- Init ----- + + onMounted(async () => { + // Load initial WPM. + if (isAuthenticated) { + // Try to sync localStorage first (one-time, idempotent). + await syncLocalToServer(); + + serverProfile = await fetchServerProfile(); + if (serverProfile?.wpm) { + wpm.value = serverProfile.wpm; + } + } else { + const local = loadLocalProfile(); + const localWpm = local.totalActiveSeconds > 0 + ? local.totalWords / (local.totalActiveSeconds / 60) + : 0; + if (localWpm > 0) { + wpm.value = localWpm; + } + } + + // Start tick. + tickIntervalId = setInterval(tick, TICK_INTERVAL_MS); + + // Activity listeners. + document.addEventListener("visibilitychange", onVisibilityChange); + document.addEventListener("mousemove", onMousemove, { passive: true }); + document.addEventListener("keydown", markActivity); + document.addEventListener("scroll", onScroll, { passive: true }); + document.addEventListener("touchstart", markActivity, { passive: true }); + document.addEventListener("pointerdown", markActivity); + window.addEventListener("focus", markActivity); + + // Flush on page navigation (SPA or classical). + window.addEventListener("beforeunload", flush); + }); + + onBeforeUnmount(async () => { + clearInterval(tickIntervalId); + + document.removeEventListener("visibilitychange", onVisibilityChange); + document.removeEventListener("mousemove", onMousemove); + document.removeEventListener("keydown", markActivity); + document.removeEventListener("scroll", onScroll); + document.removeEventListener("touchstart", markActivity); + document.removeEventListener("pointerdown", markActivity); + window.removeEventListener("focus", markActivity); + window.removeEventListener("beforeunload", flush); + + await flush(); + }); + + return { + wpm: readonly(wpm), + activeSeconds: readonly(activeSeconds) + }; +} From 98f2203e76685ce04005b9b18e99047c5fc8a6c0 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sun, 9 Aug 2026 01:48:59 -0700 Subject: [PATCH 02/38] fix: use Keys.FirstOrDefault() instead of PrimaryKey (null after JSON deserialization) SiteMapping.PrimaryKey is not serialized in sitemap.json; after deserialization it is null, causing ArgumentNullException in WordCountService constructor and HomeController when calling Dictionary.ContainsKey(null). Use mapping.Keys.FirstOrDefault() ?? mapping.PrimaryKey consistently, matching the pattern already used in BookToolQueryService. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- EssentialCSharp.Web/Controllers/HomeController.cs | 9 +++++---- EssentialCSharp.Web/Services/WordCountService.cs | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/EssentialCSharp.Web/Controllers/HomeController.cs b/EssentialCSharp.Web/Controllers/HomeController.cs index ea1b8522..1d593747 100644 --- a/EssentialCSharp.Web/Controllers/HomeController.cs +++ b/EssentialCSharp.Web/Controllers/HomeController.cs @@ -60,19 +60,20 @@ public IActionResult Index() string headHtml = doc.DocumentNode.Element("html")?.Element("head")?.InnerHtml ?? string.Empty; string html = doc.DocumentNode.Element("html")?.Element("body")?.InnerHtml ?? string.Empty; + string pageKey = siteMapping.Keys.FirstOrDefault() ?? siteMapping.PrimaryKey ?? string.Empty; ViewBag.PageTitle = siteMapping.IndentLevel is 0 ? siteMapping.ChapterTitle + " " + siteMapping.RawHeading : siteMapping.RawHeading; ViewData["Description"] = chapterDescriptionService.GetDescription(siteMapping); ViewData["CanonicalUrl"] = $"{siteSettings.Value.BaseUrl.TrimEnd('/')}{Request.PathBase}{canonicalPath}"; ViewBag.NextPage = FlipPage(siteMapping!.ChapterNumber, siteMapping.PageNumber, true); - ViewBag.CurrentPageKey = siteMapping.PrimaryKey; + ViewBag.CurrentPageKey = pageKey; ViewBag.PreviousPage = FlipPage(siteMapping.ChapterNumber, siteMapping.PageNumber, false); ViewBag.HeadContents = headHtml; ViewBag.Contents = html; ViewBag.NeedsPageHeading = doc.DocumentNode.SelectSingleNode("//body//h1") is null; - ViewBag.PageWordCount = wordCountService.GetPageWordCount(siteMapping.PrimaryKey); + ViewBag.PageWordCount = wordCountService.GetPageWordCount(pageKey); ViewBag.ChapterWordCount = wordCountService.GetChapterWordCount(siteMapping.ChapterNumber); - ViewBag.WordsBeforePage = wordCountService.GetWordsBeforePage(siteMapping.PrimaryKey); - ViewBag.ChapterStartWords = wordCountService.GetChapterStartWords(siteMapping.PrimaryKey); + ViewBag.WordsBeforePage = wordCountService.GetWordsBeforePage(pageKey); + ViewBag.ChapterStartWords = wordCountService.GetChapterStartWords(pageKey); ViewBag.BookWordCount = wordCountService.GetBookWordCount(); return View(); } diff --git a/EssentialCSharp.Web/Services/WordCountService.cs b/EssentialCSharp.Web/Services/WordCountService.cs index c951a53b..64400162 100644 --- a/EssentialCSharp.Web/Services/WordCountService.cs +++ b/EssentialCSharp.Web/Services/WordCountService.cs @@ -43,8 +43,8 @@ public WordCountService(ISiteMappingService siteMappingService, IWebHostEnvironm foreach (SiteMapping mapping in orderedMappings) { - string pageKey = mapping.PrimaryKey; - if (_pageWordCounts.ContainsKey(pageKey)) + string? pageKey = mapping.Keys.FirstOrDefault() ?? mapping.PrimaryKey; + if (pageKey is null || _pageWordCounts.ContainsKey(pageKey)) { // Multiple anchors on the same page; skip duplicates (already counted). continue; From 21b5b5dd2966e0ed692a91887431ce8e8e9cff32 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 7 Oct 2026 12:34:10 -0700 Subject: [PATCH 03/38] fix: address reading tracker review feedback --- .../ReadingControllerTests.cs | 184 ++++++++++++++---- .../WordCountServiceTests.cs | 47 ++++- .../Pages/Account/Manage/Index.cshtml | 9 +- .../Controllers/ReadingController.cs | 27 ++- .../Services/WordCountService.cs | 46 ++++- .../Views/Shared/_Layout.cshtml | 3 + .../src/components/ReadingTimeRemaining.vue | 12 +- .../src/composables/useReadingTracker.js | 55 +++--- .../src/utils/readingProgress.js | 9 + 9 files changed, 289 insertions(+), 103 deletions(-) create mode 100644 EssentialCSharp.Web/src/utils/readingProgress.js diff --git a/EssentialCSharp.Web.Tests/ReadingControllerTests.cs b/EssentialCSharp.Web.Tests/ReadingControllerTests.cs index 10289651..d7e295b7 100644 --- a/EssentialCSharp.Web.Tests/ReadingControllerTests.cs +++ b/EssentialCSharp.Web.Tests/ReadingControllerTests.cs @@ -1,12 +1,18 @@ using System.Net; using System.Net.Http.Json; +using System.Text.RegularExpressions; using EssentialCSharp.Web.Controllers; +using EssentialCSharp.Web.Data; +using EssentialCSharp.Web.Models; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace EssentialCSharp.Web.Tests; public class ReadingControllerTests : IntegrationTestBase { - [Test] public async Task GetBookStats_IsPublic_Returns200() { @@ -61,61 +67,69 @@ public async Task PostReset_Anonymous_Returns401() await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); } - // ---- WPM algorithm unit tests (test logic directly, no HTTP) ---- - [Test] - public async Task WpmAlgorithm_FastInterval_IsDiscarded() + public async Task PostSession_IntervalAboveHardCutoff_DoesNotChangePersistedProfile() { - // Hard discard: >900 WPM → interval dropped entirely - long discardWords = 901; - long discardSeconds = 60; - double discardWpm = discardWords / (discardSeconds / 60.0); // 901 WPM + string userId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + using var request = CreateSessionRequest( + [new ReadingController.ReadingIntervalDto("fast-page", 60, 901, false)]); - await Assert.That(discardWpm).IsGreaterThan(900.0); + using HttpResponseMessage response = await client.SendAsync(request); - // Accepted interval: 600 WPM (below cutoff) - long acceptWords = 600; - long acceptSeconds = 60; - double acceptWpm = acceptWords / (acceptSeconds / 60.0); // 600 WPM - - await Assert.That(acceptWpm).IsLessThanOrEqualTo(900.0); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + UserReadingProfile profile = await GetProfileAsync(userId); + await Assert.That(profile.TotalWordsRead).IsEqualTo(1200L); + await Assert.That(profile.TotalActiveSeconds).IsEqualTo(360L); + await Assert.That(profile.DeriveWpm()).IsEqualTo(200.0); + await Assert.That(await GetActivityCountAsync(userId)).IsEqualTo(0); } [Test] - public async Task WpmAlgorithm_SlowInterval_IsClamped() + public async Task PostSession_SlowInterval_ClampsTimeInPersistedProfile() { - // Reference WPM = 200. An interval of 10 words in 300 seconds = - // 10 / (300/60) = 2 WPM. 0.25 * 200 = 50 WPM → interval is below threshold → clamp. - double referenceWpm = 200.0; - double slowOutlierFactor = 0.25; - double intervalWpm = 10.0 / (300.0 / 60.0); // ~2 WPM + string userId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + using var request = CreateSessionRequest( + [new ReadingController.ReadingIntervalDto("slow-page", 300, 10, false)]); - bool shouldClamp = intervalWpm < slowOutlierFactor * referenceWpm; - await Assert.That(shouldClamp).IsTrue(); + using HttpResponseMessage response = await client.SendAsync(request); - // After clamp: effectiveSeconds = words / (0.25 * referenceWpm) * 60 - int words = 10; - int effectiveSeconds = (int)Math.Round(words / (slowOutlierFactor * referenceWpm) * 60.0); - double clampedWpm = words / (effectiveSeconds / 60.0); - - // Clamped WPM should equal exactly 0.25 * referenceWpm (within rounding) - await Assert.That(clampedWpm).IsEqualTo(50.0).Within(1.0); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + UserReadingProfile profile = await GetProfileAsync(userId); + await Assert.That(profile.TotalWordsRead).IsEqualTo(1210L); + await Assert.That(profile.TotalActiveSeconds).IsEqualTo(372L); + await Assert.That( + Math.Abs(profile.DeriveWpm()!.Value - 1210.0 / (372.0 / 60.0)) < 0.001).IsTrue(); + + ReadingActivity activity = await GetSingleActivityAsync(userId); + await Assert.That(activity.PageKey).IsEqualTo("slow-page"); + await Assert.That(activity.WordsRead).IsEqualTo(10); + await Assert.That(activity.ActiveSeconds).IsEqualTo(300); } [Test] - public async Task WpmAlgorithm_NormalInterval_PassesThrough() + public async Task PostSession_NormalInterval_AddsUnmodifiedWordsAndTimeToPersistedProfile() { - // 180 WPM interval with referenceWpm = 200. - // 180 > 0.25 * 200 = 50, so no clamping. - double referenceWpm = 200.0; - const double slowOutlierFactor = 0.25; - double intervalWpm = 180.0; + string userId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + using var request = CreateSessionRequest( + [new ReadingController.ReadingIntervalDto("normal-page", 60, 180, true)]); - bool shouldClamp = intervalWpm < slowOutlierFactor * referenceWpm; - await Assert.That(shouldClamp).IsFalse(); + using HttpResponseMessage response = await client.SendAsync(request); - bool shouldDiscard = intervalWpm > 900.0; - await Assert.That(shouldDiscard).IsFalse(); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + UserReadingProfile profile = await GetProfileAsync(userId); + await Assert.That(profile.TotalWordsRead).IsEqualTo(1380L); + await Assert.That(profile.TotalActiveSeconds).IsEqualTo(420L); + await Assert.That( + Math.Abs(profile.DeriveWpm()!.Value - 1380.0 / (420.0 / 60.0)) < 0.001).IsTrue(); + + ReadingActivity activity = await GetSingleActivityAsync(userId); + await Assert.That(activity.PageKey).IsEqualTo("normal-page"); + await Assert.That(activity.WordsRead).IsEqualTo(180); + await Assert.That(activity.ActiveSeconds).IsEqualTo(60); + await Assert.That(activity.Completed).IsTrue(); } [Test] @@ -142,4 +156,94 @@ private sealed class ChapterInfo public int ChapterNumber { get; set; } public int WordCount { get; set; } } + + private async Task CreateUserWithBaselineProfileAsync() + { + string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-test"); + await InServiceScopeAsync(async services => + { + EssentialCSharpWebContext context = + services.GetRequiredService(); + context.UserReadingProfiles.Add(new UserReadingProfile + { + UserId = userId, + TotalWordsRead = 1200, + TotalActiveSeconds = 360 + }); + await context.SaveChangesAsync(); + }); + return userId; + } + + private async Task CreateAuthenticatedClientAsync(string userId) + { + HttpClient client = McpTestHelper.CreateClient(Factory); + (string cookieName, string cookieValue) = + await McpTestHelper.CreateIdentityApplicationCookieAsync(Factory, userId); + client.DefaultRequestHeaders.Add("Cookie", $"{cookieName}={cookieValue}"); + + using HttpResponseMessage pageResponse = await client.GetAsync("/about"); + string pageContent = await pageResponse.Content.ReadAsStringAsync(); + Match requestToken = Regex.Match( + pageContent, + ">() + .Value.Cookie.Name + ?? throw new InvalidOperationException("The antiforgery cookie name is not configured."); + string antiforgeryCookie = pageResponse.Headers.GetValues("Set-Cookie") + .Select(value => value.Split(';', 2)[0]) + .Single(value => value.StartsWith( + $"{antiforgeryCookieName}=", + StringComparison.Ordinal)); + + client.DefaultRequestHeaders.Remove("Cookie"); + client.DefaultRequestHeaders.Add( + "Cookie", + $"{cookieName}={cookieValue}; {antiforgeryCookie}"); + client.DefaultRequestHeaders.Add( + "RequestVerificationToken", + requestToken.Groups[1].Value); + + return client; + } + + private static HttpRequestMessage CreateSessionRequest( + IEnumerable intervals) + { + return new HttpRequestMessage(HttpMethod.Post, "/api/reading/session") + { + Content = JsonContent.Create(intervals) + }; + } + + private async Task GetProfileAsync(string userId) + { + using IServiceScope scope = + Factory.Services.GetRequiredService().CreateScope(); + return await scope.ServiceProvider.GetRequiredService() + .UserReadingProfiles.SingleAsync(profile => profile.UserId == userId); + } + + private async Task GetActivityCountAsync(string userId) + { + using IServiceScope scope = + Factory.Services.GetRequiredService().CreateScope(); + return await scope.ServiceProvider.GetRequiredService() + .ReadingActivities.CountAsync(activity => activity.UserId == userId); + } + + private async Task GetSingleActivityAsync(string userId) + { + using IServiceScope scope = + Factory.Services.GetRequiredService().CreateScope(); + return await scope.ServiceProvider.GetRequiredService() + .ReadingActivities.SingleAsync(activity => activity.UserId == userId); + } } diff --git a/EssentialCSharp.Web.Tests/WordCountServiceTests.cs b/EssentialCSharp.Web.Tests/WordCountServiceTests.cs index 3dcf5557..2c2da0b5 100644 --- a/EssentialCSharp.Web.Tests/WordCountServiceTests.cs +++ b/EssentialCSharp.Web.Tests/WordCountServiceTests.cs @@ -17,12 +17,19 @@ private static (string tempDir, string filePath) WriteTempHtmlFile(string html) return (tempDir, filePath); } - private static SiteMapping MakeMapping(string key, string contentRoot, string relativePath, int chapter, int page, int order = 1) + private static SiteMapping MakeMapping( + string key, + string contentRoot, + string relativePath, + int chapter, + int page, + int order = 1, + string[]? keys = null) { // PagePath is relative to contentRoot as individual path segments. string[] segments = relativePath.Split('/', '\\').Where(s => s.Length > 0).ToArray(); return new SiteMapping( - keys: [key], + keys: keys?.ToList() ?? [key], primaryKey: key, pagePath: segments, chapterNumber: chapter, @@ -229,4 +236,40 @@ public async Task GetWordsBeforePage_ReturnsCorrectPrefixSum() Directory.Delete(tempDir, recursive: true); } } + + [Test] + public async Task DuplicatePageMappings_CountOnceAndShareWordOffsetsAcrossAllKeys() + { + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "a.html"), "

one two three

"); + File.WriteAllText(Path.Combine(tempDir, "b.html"), "

four five

"); + + SiteMapping[] mappings = + [ + MakeMapping("page-one", tempDir, "a.html", 1, 1, keys: ["page-one", "page-one-alias"]), + MakeMapping("page-one-heading", tempDir, ".\\a.html", 1, 1, order: 2), + MakeMapping("page-two", tempDir, "b.html", 1, 2), + ]; + (WordCountService service, _) = CreateService(mappings, tempDir); + + await Assert.That(service.GetPageWordCount("page-one")).IsEqualTo(3); + await Assert.That(service.GetPageWordCount("page-one-alias")).IsEqualTo(3); + await Assert.That(service.GetPageWordCount("page-one-heading")).IsEqualTo(3); + await Assert.That(service.GetWordsBeforePage("page-one")).IsEqualTo(0); + await Assert.That(service.GetWordsBeforePage("page-one-alias")).IsEqualTo(0); + await Assert.That(service.GetWordsBeforePage("page-one-heading")).IsEqualTo(0); + await Assert.That(service.GetChapterStartWords("page-one-alias")).IsEqualTo(0); + await Assert.That(service.GetWordsBeforePage("page-two")).IsEqualTo(3); + await Assert.That(service.GetChapterStartWords("page-two")).IsEqualTo(3); + await Assert.That(service.GetChapterWordCount(1)).IsEqualTo(5); + await Assert.That(service.GetBookWordCount()).IsEqualTo(5); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } } diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml index 549d2667..6dd813ba 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml @@ -46,7 +46,7 @@ ViewData["ActivePage"] = ManageNavPages.Index; data-confirm="Reset your reading speed? This cannot be undone."> Reset reading speed - + @@ -67,7 +67,12 @@ ViewData["ActivePage"] = ManageNavPages.Index; status.className = 'mt-2 small text-muted'; try { - const res = await fetch('/api/reading/reset', { method: 'POST' }); + const res = await fetch('/api/reading/reset', { + method: 'POST', + headers: { + 'RequestVerificationToken': document.querySelector('meta[name="csrf-token"]')?.content ?? '' + } + }); if (res.ok) { try { localStorage.removeItem('readingProfile'); } catch {} status.textContent = 'Reading speed reset successfully.'; diff --git a/EssentialCSharp.Web/Controllers/ReadingController.cs b/EssentialCSharp.Web/Controllers/ReadingController.cs index 00719944..4b71cb89 100644 --- a/EssentialCSharp.Web/Controllers/ReadingController.cs +++ b/EssentialCSharp.Web/Controllers/ReadingController.cs @@ -182,20 +182,19 @@ public async Task PostSession( await context.SaveChangesAsync(cancellationToken); // Trim ReadingActivity to the newest 500 rows for this user. - // We do this after SaveChanges so the new rows are visible in the sub-query. - await context.Database.ExecuteSqlRawAsync( - """ - DELETE FROM [ReadingActivities] - WHERE [UserId] = {0} - AND [Id] NOT IN ( - SELECT TOP ({1}) [Id] - FROM [ReadingActivities] - WHERE [UserId] = {0} - ORDER BY [RecordedAtUtc] DESC - ) - """, - [userId, MaxReadingActivityRowsPerUser], - cancellationToken); + // Use EF's provider-translated delete so retention works across supported databases. + IQueryable retainedActivityIds = context.ReadingActivities + .Where(activity => activity.UserId == userId) + .OrderByDescending(activity => activity.RecordedAtUtc) + .ThenByDescending(activity => activity.Id) + .Take(MaxReadingActivityRowsPerUser) + .Select(activity => activity.Id); + + await context.ReadingActivities + .Where(activity => + activity.UserId == userId && + !retainedActivityIds.Contains(activity.Id)) + .ExecuteDeleteAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); } diff --git a/EssentialCSharp.Web/Services/WordCountService.cs b/EssentialCSharp.Web/Services/WordCountService.cs index 64400162..8796e703 100644 --- a/EssentialCSharp.Web/Services/WordCountService.cs +++ b/EssentialCSharp.Web/Services/WordCountService.cs @@ -22,6 +22,7 @@ public class WordCountService : IWordCountService private readonly Dictionary _wordsBeforePage; private readonly Dictionary _chapterStartWords; private readonly IReadOnlyList _chapterWordCountList; + private readonly Dictionary _pagesByPath; public WordCountService(ISiteMappingService siteMappingService, IWebHostEnvironment hostingEnvironment) { @@ -29,6 +30,8 @@ public WordCountService(ISiteMappingService siteMappingService, IWebHostEnvironm _chapterWordCounts = []; _wordsBeforePage = []; _chapterStartWords = []; + _pagesByPath = new Dictionary( + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); // Walk mappings in canonical reading order: chapter → page → order-on-page. IEnumerable orderedMappings = siteMappingService.SiteMappings @@ -43,10 +46,13 @@ public WordCountService(ISiteMappingService siteMappingService, IWebHostEnvironm foreach (SiteMapping mapping in orderedMappings) { - string? pageKey = mapping.Keys.FirstOrDefault() ?? mapping.PrimaryKey; - if (pageKey is null || _pageWordCounts.ContainsKey(pageKey)) + string filePath = Path.GetFullPath(Path.Join( + hostingEnvironment.ContentRootPath, + Path.Join(mapping.PagePath))); + + if (_pagesByPath.TryGetValue(filePath, out PageWordData? existingPage)) { - // Multiple anchors on the same page; skip duplicates (already counted). + MapKeys(mapping, existingPage); continue; } @@ -62,10 +68,10 @@ public WordCountService(ISiteMappingService siteMappingService, IWebHostEnvironm chapterWords = 0; } - int words = CountProseWords(hostingEnvironment.ContentRootPath, mapping.PagePath); - _pageWordCounts[pageKey] = words; - _wordsBeforePage[pageKey] = bookWords; - _chapterStartWords[pageKey] = chapterWords; + int words = CountProseWords(filePath); + PageWordData page = new(words, bookWords, chapterWords); + _pagesByPath[filePath] = page; + MapKeys(mapping, page); bookWords += words; chapterWords += words; @@ -103,9 +109,29 @@ public int GetChapterStartWords(string pageKey) => // --- - private static int CountProseWords(string contentRoot, string[] pagePath) + private void MapKeys(SiteMapping mapping, PageWordData page) + { + IEnumerable pageKeys = (mapping.Keys ?? []) + .Append(mapping.PrimaryKey) + .Where(key => !string.IsNullOrWhiteSpace(key)) + .Distinct(StringComparer.Ordinal); + + foreach (string pageKey in pageKeys) + { + // Keep the first mapping for a route key if the sitemap contains a collision. + if (_pageWordCounts.ContainsKey(pageKey)) + { + continue; + } + + _pageWordCounts[pageKey] = page.WordCount; + _wordsBeforePage[pageKey] = page.WordsBeforePage; + _chapterStartWords[pageKey] = page.ChapterStartWords; + } + } + + private static int CountProseWords(string filePath) { - string filePath = Path.Join(contentRoot, Path.Join(pagePath)); if (!File.Exists(filePath)) { return 0; @@ -151,4 +177,6 @@ private static int CountWords(string text) } return count; } + + private sealed record PageWordData(int WordCount, int WordsBeforePage, int ChapterStartWords); } diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml index 00b71da2..5460cbed 100644 --- a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml +++ b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml @@ -6,10 +6,12 @@ @using EssentialCSharp.Common @using Microsoft.AspNetCore.Identity @using EssentialCSharp.Web.Areas.Identity.Data +@using Microsoft.AspNetCore.Antiforgery @using Microsoft.Extensions.Configuration @using Microsoft.Extensions.Options @inject ISiteMappingService _SiteMappings @inject SignInManager SignInManager +@inject IAntiforgery Antiforgery @inject IConfiguration Configuration @inject IOptions _CaptchaOptions @inject IOptions SiteSettings @@ -28,6 +30,7 @@ } + @if (Context.Response.StatusCode < 400 && !Context.Request.Path.StartsWithSegments("/Identity")) diff --git a/EssentialCSharp.Web/src/components/ReadingTimeRemaining.vue b/EssentialCSharp.Web/src/components/ReadingTimeRemaining.vue index 66884d0e..d95ec63f 100644 --- a/EssentialCSharp.Web/src/components/ReadingTimeRemaining.vue +++ b/EssentialCSharp.Web/src/components/ReadingTimeRemaining.vue @@ -1,6 +1,7 @@ + } diff --git a/EssentialCSharp.Web/src/App.vue b/EssentialCSharp.Web/src/App.vue index ca7929b9..df994d3c 100644 --- a/EssentialCSharp.Web/src/App.vue +++ b/EssentialCSharp.Web/src/App.vue @@ -8,10 +8,18 @@ import SnackbarMessage from "./components/SnackbarMessage.vue"; import ChatWidget from "./components/ChatWidget.vue"; import CodeRunnerModal from "./components/CodeRunnerModal.vue"; import { useSiteShell } from "./composables/useSiteShell.js"; +import { READING_TRACKER_KEY, useReadingTracker } from "./composables/useReadingTracker.js"; +import { getReadingContext } from "./utils/readingContext.js"; const shell = reactive(useSiteShell()); provide("shell", shell); +// Only content pages emit window.READING_CONTEXT; elsewhere the tracker is not created at all. +const readingContext = getReadingContext(); +if (readingContext) { + provide(READING_TRACKER_KEY, useReadingTracker(readingContext)); +} + function updateLayoutClasses() { const siteLayout = document.getElementById("site-layout"); if (!siteLayout) { diff --git a/EssentialCSharp.Web/src/components/ReadingTimeRemaining.vue b/EssentialCSharp.Web/src/components/ReadingTimeRemaining.vue index d95ec63f..e5d232ed 100644 --- a/EssentialCSharp.Web/src/components/ReadingTimeRemaining.vue +++ b/EssentialCSharp.Web/src/components/ReadingTimeRemaining.vue @@ -1,83 +1,66 @@ diff --git a/EssentialCSharp.Web/src/composables/useActivityState.js b/EssentialCSharp.Web/src/composables/useActivityState.js new file mode 100644 index 00000000..24dfebc0 --- /dev/null +++ b/EssentialCSharp.Web/src/composables/useActivityState.js @@ -0,0 +1,80 @@ +/** + * Activity detection (idle / visibility / input / focus) with no tracking logic. + * + * Rules: + * - Starts IDLE: an untouched page never accrues time until real input. + * - A bare window "focus" event is NOT input. + * - Scroll counts as activity only after the first real input (scrolls caused + * by anchors / scroll restoration before that are ignored). + * - READING requires the document to be visible AND focused. With several tabs + * or windows open only the focused one counts, which mitigates double + * counting across tabs. + */ +import { onScopeDispose } from "vue"; +import { IDLE_THRESHOLD_S, MOUSEMOVE_THROTTLE_MS } from "../utils/readingConstants.js"; + +export const STATE_READING = "READING"; +export const STATE_IDLE = "IDLE"; +export const STATE_HIDDEN = "HIDDEN"; + +/** + * @param {{ visible: boolean, focused: boolean, lastInputAt: number | null, now: number }} input + * timestamps in ms from the same clock + * @returns {"READING" | "IDLE" | "HIDDEN"} + */ +export function computeActivityState({ visible, focused, lastInputAt, now }) { + if (!visible || !focused) return STATE_HIDDEN; + if (lastInputAt === null || (now - lastInputAt) / 1000 > IDLE_THRESHOLD_S) return STATE_IDLE; + return STATE_READING; +} + +export function useActivityState() { + let lastInputAt = null; + let lastMousemoveAt = 0; + + function markInput() { + lastInputAt = performance.now(); + } + + function markScroll() { + if (lastInputAt !== null) markInput(); + } + + function onMousemove() { + const now = performance.now(); + if (now - lastMousemoveAt >= MOUSEMOVE_THROTTLE_MS) { + lastMousemoveAt = now; + markInput(); + } + } + + const listeners = [ + [document, "mousemove", onMousemove], + [document, "wheel", markInput], + [document, "touchstart", markInput], + [document, "keydown", markInput], + [document, "pointerdown", markInput], + [window, "scroll", markScroll] + ]; + for (const [target, type, handler] of listeners) { + target.addEventListener(type, handler, { passive: true }); + } + + onScopeDispose(() => { + for (const [target, type, handler] of listeners) { + target.removeEventListener(type, handler); + } + }); + + return { + getState: () => computeActivityState({ + visible: document.visibilityState === "visible", + focused: document.hasFocus(), + lastInputAt, + now: performance.now() + }), + hasUserInput: () => lastInputAt !== null, + /** Forget input (e.g. page restored from bfcache) so the page starts IDLE again. */ + reset: () => { lastInputAt = null; } + }; +} diff --git a/EssentialCSharp.Web/src/composables/useReadingTracker.js b/EssentialCSharp.Web/src/composables/useReadingTracker.js index cacb9cd0..b040156d 100644 --- a/EssentialCSharp.Web/src/composables/useReadingTracker.js +++ b/EssentialCSharp.Web/src/composables/useReadingTracker.js @@ -1,327 +1,201 @@ /** - * useReadingTracker — Kindle-style active-reading time tracker. + * useReadingTracker - thin orchestrator for the reading-time feature. * - * State machine: READING → IDLE (after IDLE_THRESHOLD s of no input) - * → HIDDEN (tab not visible) - * Only accumulates activeSeconds while in READING state. + * Runs once at app level (see App.vue) and shares state with the header widget + * through provide/inject (READING_TRACKER_KEY). Pages are full server-rendered + * reloads, so flushing is driven by the page lifecycle (visibilitychange to + * hidden, pagehide), never by component unmount. * - * Persists a rolling aggregate to: - * - Server (POST /api/reading/session) when authenticated. - * - localStorage key "readingProfile" for anonymous users and as a backup. + * - Anonymous visitors are not tracked: they only get a scroll fraction and the + * DEFAULT_WPM estimate. No timers, input listeners or requests. + * - Signed-in visitors: the server is authoritative for WPM (fetched once per + * page load) and receives delta-based session intervals. * - * On first authenticated page load, uploads the localStorage aggregate to the - * server and clears local storage (one-time sync after login). + * Pieces: useActivityState (idle/visibility/focus/input), services/readingApi + * (fetch + CSRF + typed results), utils/readingProgress (pure scroll math). */ +import { onScopeDispose, readonly, ref } from "vue"; +import { STATE_READING, useActivityState } from "./useActivityState.js"; +import { + STATUS_AUTH_FAILURE, + STATUS_OK, + fetchProfile, + postSession +} from "../services/readingApi.js"; +import { + COMPLETION_THRESHOLD, + DEFAULT_WPM, + MAX_CREDIT_WPM, + MAX_TICK_DELTA_S, + PERIODIC_FLUSH_ACTIVE_S, + TICK_INTERVAL_MS +} from "../utils/readingConstants.js"; +import { + creditWords, + displayFraction, + findContentElement, + fractionAboveViewportBottom, + fractionAboveViewportTop, + measureContent +} from "../utils/readingProgress.js"; + +/** Injection key for the tracker state consumed by ReadingTimeRemaining. */ +export const READING_TRACKER_KEY = Symbol("readingTracker"); -import { ref, onMounted, onBeforeUnmount, readonly } from "vue"; -import { getDocumentScrollFraction } from "../utils/readingProgress.js"; -import { applyOutlierRules, deriveWpm, getSessionWordsRead } from "../utils/readingAlgorithm.js"; - -// ----- Constants ----- -const IDLE_THRESHOLD_S = 300; // 5 minutes -const TICK_INTERVAL_MS = 1000; // 1 second timer -const DEFAULT_WPM = 200; // cold-start display default -const MOUSEMOVE_THROTTLE_MS = 1000; -const LOCAL_STORAGE_KEY = "readingProfile"; - -// ----- States ----- -const STATE_READING = "READING"; -const STATE_IDLE = "IDLE"; -const STATE_HIDDEN = "HIDDEN"; - -// ----- localStorage helpers ----- - -function loadLocalProfile() { - try { - const raw = localStorage.getItem(LOCAL_STORAGE_KEY); - if (!raw) return { totalWords: 0, totalActiveSeconds: 0 }; - return JSON.parse(raw); - } catch { - return { totalWords: 0, totalActiveSeconds: 0 }; - } -} - -function saveLocalProfile(profile) { - try { - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(profile)); - } catch { - // Ignore storage errors (private browsing quota exceeded, etc.) - } -} - -function clearLocalProfile() { - try { - localStorage.removeItem(LOCAL_STORAGE_KEY); - } catch { - // Ignore - } -} - -// ----- API helpers ----- - -async function fetchServerProfile() { - try { - const res = await fetch("/api/reading/profile"); - if (!res.ok) return null; - return await res.json(); - } catch { - return null; - } -} - -async function postSession(intervals, { keepalive = false } = {}) { - if (!intervals || intervals.length === 0) return null; - try { - const res = await fetch("/api/reading/session", { - method: "POST", - headers: { - "Content-Type": "application/json", - "RequestVerificationToken": document.querySelector('meta[name="csrf-token"]')?.content ?? "" - }, - body: JSON.stringify(intervals), - keepalive - }); - if (!res.ok) return null; - return await res.json(); - } catch { - return null; - } -} - -// ----- Composable ----- +/** + * @typedef {Object} ReadingTracker + * @property {import("vue").Ref} wpm words per minute used for estimates + * @property {import("vue").Ref} scrollFraction 0..1 progress through the page content + * @property {import("../utils/readingContext.js").ReadingContext} context + */ -export function useReadingTracker() { - // Reactive WPM shown in the UI. +/** + * @param {import("../utils/readingContext.js").ReadingContext} context + * @returns {ReadingTracker} + */ +export function useReadingTracker(context) { const wpm = ref(DEFAULT_WPM); - const activeSeconds = ref(0); - - // Reading state machine. - let state = document.visibilityState === "visible" ? STATE_READING : STATE_HIDDEN; - let lastActivityAt = Date.now(); - let tickIntervalId = null; - let lastMousemoveAt = 0; - - // Scroll tracking. Session credit is measured from the initial position, so - // deep links and restored scroll positions do not credit skipped content. - let sessionStartScrollFraction = getDocumentScrollFraction(); - let maxScrollFraction = sessionStartScrollFraction; - let hasUserInput = false; - - // Page context (from window globals set by _Layout.cshtml). - const pageKey = window.CURRENT_PAGE_KEY ?? null; - const pageWordCount = window.PAGE_WORD_COUNT ?? 0; - const isAuthenticated = Boolean(window.IS_AUTHENTICATED); - - // In-session server profile for reference WPM (loaded on mount). - let serverProfile = null; - - // ----- Scroll fraction ----- - - // ----- State machine ----- - - function markActivity() { - lastActivityAt = Date.now(); - if (state === STATE_IDLE || state === STATE_HIDDEN) { - state = STATE_READING; - } - } - - function markUserInput() { - hasUserInput = true; - markActivity(); - } - - function onVisibilityChange() { - if (document.visibilityState !== "visible") { - state = STATE_HIDDEN; - } else { - // Tab became visible — READING if recently active, else IDLE. - const idleDuration = (Date.now() - lastActivityAt) / 1000; - state = idleDuration <= IDLE_THRESHOLD_S ? STATE_READING : STATE_IDLE; - } - } - - function onMousemove() { - const now = Date.now(); - if (now - lastMousemoveAt >= MOUSEMOVE_THROTTLE_MS) { - lastMousemoveAt = now; - markUserInput(); + const scrollFraction = ref(0); + + const tracking = context.isAuthenticated && context.pageWords > 0; + const activity = tracking ? useActivityState() : null; + + let disposed = false; + let authFailed = false; + let inFlight = false; + let rafId = 0; + let tickId = 0; + + // Unsent active time (fractional seconds) and scroll baseline/high-water + // mark, both as fractions of the page content. Flushes send the delta and + // rebase these so nothing is ever sent twice. + let pendingSeconds = 0; + let baseline = 0; + let maxBottom = 0; + let lastTickAt = performance.now(); + + const contentElement = findContentElement(); + + function updateProgress() { + const measure = measureContent(contentElement); + scrollFraction.value = displayFraction(measure); + const bottom = fractionAboveViewportBottom(measure); + if (!activity?.hasUserInput()) { + // Anchors / scroll restoration before real input only move the baseline. + baseline = fractionAboveViewportTop(measure); + maxBottom = bottom; + } else if (bottom > maxBottom) { + maxBottom = bottom; } } - function onScroll() { - const fraction = getDocumentScrollFraction(); - if (!hasUserInput) { - // Browser anchor/restoration scrolls before reader input reset the baseline. - sessionStartScrollFraction = fraction; - maxScrollFraction = fraction; - return; - } - - markActivity(); - if (fraction > maxScrollFraction) { - maxScrollFraction = fraction; - } + function scheduleProgressUpdate() { + if (rafId) return; + rafId = requestAnimationFrame(() => { + rafId = 0; + if (!disposed) updateProgress(); + }); } - // ----- Tick ----- - function tick() { - const now = Date.now(); - const idleDuration = (now - lastActivityAt) / 1000; - - if (state === STATE_READING && idleDuration > IDLE_THRESHOLD_S) { - state = STATE_IDLE; - } - - if (state === STATE_READING) { - activeSeconds.value++; - } + const now = performance.now(); + const deltaS = Math.min((now - lastTickAt) / 1000, MAX_TICK_DELTA_S); + lastTickAt = now; + if (activity.getState() === STATE_READING) pendingSeconds += deltaS; + updateProgress(); + if (pendingSeconds >= PERIODIC_FLUSH_ACTIVE_S) void flush(); } - // ----- Flush (send data on navigation away / unmount) ----- - + /** + * Sends the unsent delta. Lifecycle flushes (keepalive) always send; the + * periodic one yields to an in-flight request. + */ async function flush({ keepalive = false } = {}) { - if (!pageKey || activeSeconds.value <= 0 || pageWordCount <= 0) return; + if (!tracking || authFailed || (inFlight && !keepalive)) return; - const wordsRead = getSessionWordsRead( - pageWordCount, - sessionStartScrollFraction, - maxScrollFraction); - const scrollFraction = maxScrollFraction; - // Near-bottom (within 5%) = completed. - const completed = scrollFraction >= 0.95; + updateProgress(); + const activeSeconds = Math.floor(pendingSeconds); + if (activeSeconds < 1) return; + + const wordsRead = creditWords( + context.pageWords, maxBottom - baseline, activeSeconds, MAX_CREDIT_WPM); + // Nothing progressed: keep the time pending for when the reader scrolls. + if (wordsRead <= 0) return; const interval = { - pageKey, - activeSeconds: activeSeconds.value, + pageKey: context.pageKey, + activeSeconds, wordsRead, - completed + completed: maxBottom >= COMPLETION_THRESHOLD }; - if (isAuthenticated) { - const updated = await postSession([interval], { keepalive }); - if (updated) { - serverProfile = updated; - if (updated.wpm) { - wpm.value = updated.wpm; - } - } - } else { - // Update localStorage profile with the same outlier rules as the server. - const local = loadLocalProfile(); - const totalWords = local.totalWords || 0; - const totalActiveSeconds = local.totalActiveSeconds || 0; - const accepted = applyOutlierRules( - wordsRead, - activeSeconds.value, - deriveWpm(totalWords, totalActiveSeconds)); - if (!accepted) return; - - local.totalWords = totalWords + accepted.words; - local.totalActiveSeconds = totalActiveSeconds + accepted.seconds; - saveLocalProfile(local); + // Rebase before sending so a second flush cannot re-send this delta. + pendingSeconds -= activeSeconds; + baseline = maxBottom; - const localWpm = deriveWpm(local.totalWords, local.totalActiveSeconds); - if (localWpm > 0) { - wpm.value = localWpm; - } - } + inFlight = true; + const result = await postSession([interval], { keepalive }); + inFlight = false; + // Failures are dropped (no retry queue), but an expired session stops further posts. + if (result.status === STATUS_AUTH_FAILURE) authFailed = true; } - // ----- One-time sync: localStorage → server on first authenticated load ----- - - async function syncLocalToServer() { - if (!isAuthenticated) return; - - const local = loadLocalProfile(); - if (!local || (local.totalWords === 0 && local.totalActiveSeconds === 0)) return; - - // Only upload if server has no data yet. - const profile = await fetchServerProfile(); - if (profile && (profile.totalWordsRead > 0 || profile.totalActiveSeconds > 0)) { - // Server already has data — just clear local. - clearLocalProfile(); - return; + function onVisibilityChange() { + if (document.visibilityState === "hidden") { + void flush({ keepalive: true }); + } else { + lastTickAt = performance.now(); } - - // Upload local aggregate as a synthetic interval. - if (local.totalWords <= 0 || local.totalActiveSeconds <= 0) return; - - const uploaded = await postSession([{ - pageKey: "__localStorage_sync__", - activeSeconds: local.totalActiveSeconds, - wordsRead: local.totalWords, - completed: false - }]); - if (!uploaded) return; - - clearLocalProfile(); } - function onBeforeUnload() { + function onPageHide() { void flush({ keepalive: true }); } - // ----- Init ----- + function onPageShow(event) { + if (!event.persisted) return; + // Restored from the back/forward cache: start fresh and IDLE. + activity?.reset(); + lastTickAt = performance.now(); + updateProgress(); + } - onMounted(async () => { - state = document.visibilityState === "visible" ? STATE_READING : STATE_HIDDEN; - if (!hasUserInput) { - sessionStartScrollFraction = getDocumentScrollFraction(); - maxScrollFraction = sessionStartScrollFraction; + async function loadProfile() { + const result = await fetchProfile(); + if (disposed) return; + if (result.status === STATUS_AUTH_FAILURE) { + authFailed = true; + } else if (result.status === STATUS_OK && result.data?.wpm > 0) { + wpm.value = result.data.wpm; } + } - tickIntervalId = setInterval(tick, TICK_INTERVAL_MS); + // Progress display works for everyone. + const onScrollOrResize = () => scheduleProgressUpdate(); + window.addEventListener("scroll", onScrollOrResize, { passive: true }); + window.addEventListener("resize", onScrollOrResize, { passive: true }); + window.addEventListener("load", onScrollOrResize); + updateProgress(); + if (tracking) { document.addEventListener("visibilitychange", onVisibilityChange); - document.addEventListener("mousemove", onMousemove, { passive: true }); - document.addEventListener("keydown", markUserInput); - document.addEventListener("wheel", markUserInput, { passive: true }); - window.addEventListener("scroll", onScroll, { passive: true }); - document.addEventListener("touchstart", markUserInput, { passive: true }); - document.addEventListener("pointerdown", markUserInput); - window.addEventListener("focus", markActivity); - window.addEventListener("beforeunload", onBeforeUnload); - - // Load initial WPM. - if (isAuthenticated) { - // Try to sync localStorage first (one-time, idempotent). - await syncLocalToServer(); - - serverProfile = await fetchServerProfile(); - if (serverProfile?.wpm) { - wpm.value = serverProfile.wpm; - } - } else { - const local = loadLocalProfile(); - const localWpm = local.totalActiveSeconds > 0 - ? local.totalWords / (local.totalActiveSeconds / 60) - : 0; - if (localWpm > 0) { - wpm.value = localWpm; - } - } - }); - - onBeforeUnmount(async () => { - clearInterval(tickIntervalId); + window.addEventListener("pagehide", onPageHide); + window.addEventListener("pageshow", onPageShow); + tickId = window.setInterval(tick, TICK_INTERVAL_MS); + void loadProfile(); + } + onScopeDispose(() => { + disposed = true; + clearInterval(tickId); + if (rafId) cancelAnimationFrame(rafId); + window.removeEventListener("scroll", onScrollOrResize); + window.removeEventListener("resize", onScrollOrResize); + window.removeEventListener("load", onScrollOrResize); document.removeEventListener("visibilitychange", onVisibilityChange); - document.removeEventListener("mousemove", onMousemove); - document.removeEventListener("keydown", markUserInput); - document.removeEventListener("wheel", markUserInput); - window.removeEventListener("scroll", onScroll); - document.removeEventListener("touchstart", markUserInput); - document.removeEventListener("pointerdown", markUserInput); - window.removeEventListener("focus", markActivity); - window.removeEventListener("beforeunload", onBeforeUnload); - - await flush(); + window.removeEventListener("pagehide", onPageHide); + window.removeEventListener("pageshow", onPageShow); }); - return { - wpm: readonly(wpm), - activeSeconds: readonly(activeSeconds) - }; + return { wpm: readonly(wpm), scrollFraction: readonly(scrollFraction), context }; } diff --git a/EssentialCSharp.Web/src/reading-reset.js b/EssentialCSharp.Web/src/reading-reset.js new file mode 100644 index 00000000..5a2219d0 --- /dev/null +++ b/EssentialCSharp.Web/src/reading-reset.js @@ -0,0 +1,41 @@ +/** + * Behavior for the "Reset reading speed" button on the Manage Profile page. + * Loaded as its own Vite entry (no inline script, CSP-safe). + */ +import { STATUS_AUTH_FAILURE, STATUS_OK, resetProfile } from "./services/readingApi.js"; + +const STATUS_CLASSES = ["text-muted", "text-success", "text-danger"]; + +function showStatus(element, message, tone) { + element.hidden = false; + element.textContent = message; + element.classList.remove(...STATUS_CLASSES); + element.classList.add(tone); +} + +function init() { + const button = document.getElementById("reset-reading-speed-button"); + const status = document.getElementById("reset-reading-status"); + if (!button || !status) return; + + button.addEventListener("click", async () => { + if (!window.confirm(button.dataset.confirm)) return; + + button.disabled = true; + showStatus(status, "Resetting…", "text-muted"); + + const result = await resetProfile(); + if (result.status === STATUS_OK) { + showStatus(status, "Reading speed reset successfully.", "text-success"); + return; + } + + const message = result.status === STATUS_AUTH_FAILURE + ? "Your session has expired. Please sign in again." + : "Reset failed. Please try again."; + showStatus(status, message, "text-danger"); + button.disabled = false; + }); +} + +init(); diff --git a/EssentialCSharp.Web/src/services/readingApi.js b/EssentialCSharp.Web/src/services/readingApi.js new file mode 100644 index 00000000..142ee5f1 --- /dev/null +++ b/EssentialCSharp.Web/src/services/readingApi.js @@ -0,0 +1,78 @@ +import { READING_PROFILE_URL, READING_RESET_URL, READING_SESSION_URL } from "../utils/readingConstants.js"; + +/** @typedef {"ok" | "auth-failure" | "transient"} ApiStatus */ + +/** + * @typedef {Object} ReadingInterval + * @property {string} pageKey + * @property {number} activeSeconds + * @property {number} wordsRead + * @property {boolean} completed + */ + +/** + * @template T + * @typedef {{ status: ApiStatus, data?: T }} ApiResult + */ + +export const STATUS_OK = "ok"; +export const STATUS_AUTH_FAILURE = "auth-failure"; +export const STATUS_TRANSIENT = "transient"; + +/** Antiforgery token; the layout emits the meta tag for signed-in users only. */ +export function getCsrfToken() { + return document.querySelector('meta[name="csrf-token"]')?.content ?? ""; +} + +function statusFor(response) { + if (response.ok) return STATUS_OK; + return response.status === 401 || response.status === 403 ? STATUS_AUTH_FAILURE : STATUS_TRANSIENT; +} + +/** @returns {Promise>} */ +export async function fetchProfile() { + try { + const res = await fetch(READING_PROFILE_URL, { headers: { Accept: "application/json" } }); + const status = statusFor(res); + if (status !== STATUS_OK) return { status }; + return { status, data: await res.json() }; + } catch { + return { status: STATUS_TRANSIENT }; + } +} + +/** + * @param {ReadingInterval[]} intervals + * @param {{ keepalive?: boolean }} [options] + * @returns {Promise>} + */ +export async function postSession(intervals, { keepalive = false } = {}) { + try { + const res = await fetch(READING_SESSION_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + RequestVerificationToken: getCsrfToken() + }, + // The server currently binds the body to a bare array of intervals. + body: JSON.stringify(intervals), + keepalive + }); + return { status: statusFor(res) }; + } catch { + return { status: STATUS_TRANSIENT }; + } +} + +/** @returns {Promise>} */ +export async function resetProfile() { + try { + const res = await fetch(READING_RESET_URL, { + method: "POST", + headers: { RequestVerificationToken: getCsrfToken() } + }); + return { status: statusFor(res) }; + } catch { + return { status: STATUS_TRANSIENT }; + } +} diff --git a/EssentialCSharp.Web/src/utils/readingAlgorithm.js b/EssentialCSharp.Web/src/utils/readingAlgorithm.js deleted file mode 100644 index fcbb304a..00000000 --- a/EssentialCSharp.Web/src/utils/readingAlgorithm.js +++ /dev/null @@ -1,35 +0,0 @@ -// Keep these rules aligned with ReadingController.PostSession. -export const MAX_WPM_HARD_CUTOFF = 900; -export const SLOW_OUTLIER_FACTOR = 0.25; - -export function deriveWpm(totalWords, totalActiveSeconds) { - return totalWords > 0 && totalActiveSeconds > 0 - ? totalWords / (totalActiveSeconds / 60) - : 0; -} - -/** - * Applies the reading-speed outlier rules to one interval. - * Returns null when the interval must be discarded; otherwise returns the - * words and seconds that should be added to the aggregate profile. - */ -export function applyOutlierRules(wordsRead, activeSeconds, referenceWpm) { - if (activeSeconds <= 0 || wordsRead <= 0) return null; - - const intervalWpm = deriveWpm(wordsRead, activeSeconds); - if (intervalWpm > MAX_WPM_HARD_CUTOFF) return null; - - let effectiveSeconds = activeSeconds; - if (referenceWpm > 0 && intervalWpm < SLOW_OUTLIER_FACTOR * referenceWpm) { - effectiveSeconds = Math.max( - 1, - Math.ceil(wordsRead / (SLOW_OUTLIER_FACTOR * referenceWpm) * 60)); - } - - return { words: wordsRead, seconds: effectiveSeconds }; -} - -export function getSessionWordsRead(pageWordCount, startScrollFraction, maxScrollFraction) { - const progress = Math.max(0, maxScrollFraction - startScrollFraction); - return Math.round(pageWordCount * progress); -} diff --git a/EssentialCSharp.Web/src/utils/readingConstants.js b/EssentialCSharp.Web/src/utils/readingConstants.js new file mode 100644 index 00000000..1742b00a --- /dev/null +++ b/EssentialCSharp.Web/src/utils/readingConstants.js @@ -0,0 +1,30 @@ +/** Cold-start / anonymous words-per-minute used for estimates. */ +export const DEFAULT_WPM = 200; + +/** No real input for this long switches READING to IDLE (seconds). */ +export const IDLE_THRESHOLD_S = 300; + +/** Fraction of the page (viewport bottom) at which a page counts as completed. */ +export const COMPLETION_THRESHOLD = 0.95; + +/** Tick period of the active-time timer (ms). */ +export const TICK_INTERVAL_MS = 1000; + +/** Longest elapsed time one tick may add (s); guards against throttled timers and sleep/resume. */ +export const MAX_TICK_DELTA_S = 2; + +/** Minimum gap between mousemove events treated as input (ms). */ +export const MOUSEMOVE_THROTTLE_MS = 1000; + +/** + * Plausibility cap only: words credited for an interval never exceed + * activeSeconds * MAX_CREDIT_WPM / 60. The server enforces its own limits. + */ +export const MAX_CREDIT_WPM = 800; + +/** While the page stays visible and active, flush at least this often (active seconds). */ +export const PERIODIC_FLUSH_ACTIVE_S = 60; + +export const READING_PROFILE_URL = "/api/reading/profile"; +export const READING_SESSION_URL = "/api/reading/session"; +export const READING_RESET_URL = "/api/reading/reset"; diff --git a/EssentialCSharp.Web/src/utils/readingContext.js b/EssentialCSharp.Web/src/utils/readingContext.js new file mode 100644 index 00000000..0c8b7b0f --- /dev/null +++ b/EssentialCSharp.Web/src/utils/readingContext.js @@ -0,0 +1,34 @@ +/** + * @typedef {Object} ReadingContext + * @property {boolean} isAuthenticated + * @property {string} pageKey + * @property {number} pageWords + * @property {number} chapterWords + * @property {number} wordsBeforePage + * @property {number} chapterStartWords + * @property {number} bookWords + */ + +function toCount(value) { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : 0; +} + +/** + * Reads window.READING_CONTEXT (emitted by the layout on content pages only). + * @param {Window} [win] + * @returns {ReadingContext | null} null when this is not a content page. + */ +export function getReadingContext(win = window) { + const raw = win.READING_CONTEXT; + if (!raw || typeof raw !== "object" || !raw.pageKey) return null; + return { + isAuthenticated: raw.isAuthenticated === true, + pageKey: String(raw.pageKey), + pageWords: toCount(raw.pageWords), + chapterWords: toCount(raw.chapterWords), + wordsBeforePage: toCount(raw.wordsBeforePage), + chapterStartWords: toCount(raw.chapterStartWords), + bookWords: toCount(raw.bookWords) + }; +} diff --git a/EssentialCSharp.Web/src/utils/readingFormat.js b/EssentialCSharp.Web/src/utils/readingFormat.js new file mode 100644 index 00000000..50dafeec --- /dev/null +++ b/EssentialCSharp.Web/src/utils/readingFormat.js @@ -0,0 +1,49 @@ +const MINUTES_PER_HOUR = 60; + +function isValidMinutes(minutes) { + return typeof minutes === "number" && Number.isFinite(minutes) && minutes >= 0; +} + +/** + * Compact label, e.g. "less than a minute", "12 min", "2h 5m". Null for invalid input. + * @param {number | null | undefined} minutes + * @returns {string | null} + */ +export function formatMinutes(minutes) { + if (!isValidMinutes(minutes)) return null; + const rounded = Math.round(minutes); + if (rounded < 1) return "less than a minute"; + if (rounded < MINUTES_PER_HOUR) return `${rounded} min`; + const hours = Math.floor(rounded / MINUTES_PER_HOUR); + const mins = rounded % MINUTES_PER_HOUR; + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; +} + +const plural = (n, unit) => `${n} ${unit}${n === 1 ? "" : "s"}`; + +/** + * Spoken wording for screen readers, e.g. "about 12 minutes". + * @param {number | null | undefined} minutes + * @returns {string | null} + */ +export function formatMinutesSpoken(minutes) { + if (!isValidMinutes(minutes)) return null; + const rounded = Math.round(minutes); + if (rounded < 1) return "less than a minute"; + if (rounded < MINUTES_PER_HOUR) return `about ${plural(rounded, "minute")}`; + const hours = Math.floor(rounded / MINUTES_PER_HOUR); + const mins = rounded % MINUTES_PER_HOUR; + return mins > 0 + ? `about ${plural(hours, "hour")} ${plural(mins, "minute")}` + : `about ${plural(hours, "hour")}`; +} + +/** + * @param {number} wordsLeft + * @param {number} wpm + * @returns {number | null} minutes, or null when it cannot be computed + */ +export function minutesForWords(wordsLeft, wpm) { + if (!Number.isFinite(wordsLeft) || !Number.isFinite(wpm) || wpm <= 0) return null; + return Math.max(0, wordsLeft) / wpm; +} diff --git a/EssentialCSharp.Web/src/utils/readingProgress.js b/EssentialCSharp.Web/src/utils/readingProgress.js index 6b9570b8..10ace032 100644 --- a/EssentialCSharp.Web/src/utils/readingProgress.js +++ b/EssentialCSharp.Web/src/utils/readingProgress.js @@ -1,9 +1,82 @@ -export function getDocumentScrollFraction() { - const scrollingElement = document.scrollingElement ?? document.documentElement; - const scrollable = scrollingElement.scrollHeight - scrollingElement.clientHeight; +/** + * Scroll progress measured against the article/content element rather than the + * whole document, so header/footer/chrome do not skew it. + */ - if (scrollable <= 0) return 1; +const clamp01 = (n) => Math.min(1, Math.max(0, Number.isFinite(n) ? n : 0)); - const scrollTop = scrollingElement.scrollTop ?? window.scrollY ?? 0; - return Math.min(1, Math.max(0, scrollTop / scrollable)); +/** + * @typedef {Object} ContentMeasure + * @property {number} top Distance from viewport top to content top (px, may be negative). + * @property {number} height Content height (px). + * @property {number} viewportHeight + */ + +/** + * Fraction of the content that has entered the viewport (everything above the + * viewport bottom). Used for crediting words read. + * @param {ContentMeasure} m + */ +export function fractionAboveViewportBottom({ top, height, viewportHeight }) { + if (!(height > 0)) return 0; + return clamp01((viewportHeight - top) / height); +} + +/** + * Fraction of the content that has scrolled above the viewport top. Used as the + * credit baseline: content already scrolled past on load (deep links, restored + * scroll) is never credited, but the first screen is. + * @param {ContentMeasure} m + */ +export function fractionAboveViewportTop({ top, height }) { + if (!(height > 0)) return 0; + return clamp01(-top / height); +} + +/** + * Classic 0..1 "how far through the article" for display. Content that fits in + * the viewport reports 0, not 1. + * @param {ContentMeasure} m + */ +export function displayFraction({ top, height, viewportHeight }) { + const scrollable = height - viewportHeight; + if (!(scrollable > 0)) return 0; + return clamp01(-top / scrollable); +} + +/** + * Finds the element whose extent represents the readable content. + * Prefers an explicit [data-reading-content] marker, then the layout container. + * @param {Document} [doc] + * @returns {Element | null} + */ +export function findContentElement(doc = document) { + return doc.querySelector("[data-reading-content]") + ?? doc.querySelector("#site-layout > .container") + ?? doc.getElementById("site-layout"); +} + +/** + * @param {Element | null} element + * @returns {ContentMeasure} + */ +export function measureContent(element) { + const viewportHeight = window.innerHeight; + if (!element) return { top: 0, height: 0, viewportHeight }; + const { top, height } = element.getBoundingClientRect(); + return { top, height, viewportHeight }; +} + +/** + * Words credited for an interval: progress-based, capped by what is plausible + * for the active time spent. + * @param {number} pageWords + * @param {number} progress fraction of the page progressed this interval (0..1) + * @param {number} activeSeconds + * @param {number} maxWpm + */ +export function creditWords(pageWords, progress, activeSeconds, maxWpm) { + const byProgress = Math.round(pageWords * Math.max(0, progress)); + const byTime = Math.floor(Math.max(0, activeSeconds) * maxWpm / 60); + return Math.max(0, Math.min(byProgress, byTime)); } diff --git a/EssentialCSharp.Web/vite.config.js b/EssentialCSharp.Web/vite.config.js index d7ce0bbb..520dd42d 100644 --- a/EssentialCSharp.Web/vite.config.js +++ b/EssentialCSharp.Web/vite.config.js @@ -21,6 +21,7 @@ export default defineConfig({ rollupOptions: { input: { "site-shell": resolve(rootDir, "src", "main.js"), + "reading-reset": resolve(rootDir, "src", "reading-reset.js"), "password-strength": resolve(rootDir, "wwwroot", "js", "password-strength.js") }, output: { From 213f4d61bf4d5240c61810f49b0bfaa19f5a1ced Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Thu, 5 Nov 2026 21:05:13 -0800 Subject: [PATCH 09/38] refactor(reading): extract profile service and speed policy, drop ReadingActivity Replace the activity audit table with a single UserReadingProfile migration, move business rules out of the controller into IReadingProfileService and ReadingSpeedPolicy bound to ReadingOptions, use an atomic upsert, validate requests, and add per-user rate limiting. --- .../Data/EssentialCSharpWebContext.cs | 23 - .../Controllers/BookStatsResponse.cs | 5 + .../Controllers/ReadingController.cs | 305 +--- .../Controllers/ReadingIntervalRequest.cs | 26 + .../Controllers/ReadingSessionRequest.cs | 14 + .../20260809064123_AddReadingTimeTracking.cs | 80 - ...6050505_AddUserReadingProfile.Designer.cs} | 58 +- .../20261106050505_AddUserReadingProfile.cs | 42 + .../EssentialCSharpWebContextModelSnapshot.cs | 54 +- EssentialCSharp.Web/Models/ReadingActivity.cs | 43 - .../Models/UserReadingProfile.cs | 27 +- EssentialCSharp.Web/Program.cs | 1441 +++++++++-------- .../Services/IReadingProfileService.cs | 23 + .../Services/ReadingOptions.cs | 21 + .../Services/ReadingProfileService.cs | 119 ++ .../Services/ReadingSpeedPolicy.cs | 43 + 16 files changed, 1105 insertions(+), 1219 deletions(-) create mode 100644 EssentialCSharp.Web/Controllers/BookStatsResponse.cs create mode 100644 EssentialCSharp.Web/Controllers/ReadingIntervalRequest.cs create mode 100644 EssentialCSharp.Web/Controllers/ReadingSessionRequest.cs delete mode 100644 EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.cs rename EssentialCSharp.Web/Migrations/{20260809064123_AddReadingTimeTracking.Designer.cs => 20261106050505_AddUserReadingProfile.Designer.cs} (87%) create mode 100644 EssentialCSharp.Web/Migrations/20261106050505_AddUserReadingProfile.cs delete mode 100644 EssentialCSharp.Web/Models/ReadingActivity.cs create mode 100644 EssentialCSharp.Web/Services/IReadingProfileService.cs create mode 100644 EssentialCSharp.Web/Services/ReadingOptions.cs create mode 100644 EssentialCSharp.Web/Services/ReadingProfileService.cs create mode 100644 EssentialCSharp.Web/Services/ReadingSpeedPolicy.cs diff --git a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs index 545fb2c4..627be8cb 100644 --- a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs +++ b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs @@ -11,28 +11,5 @@ public class EssentialCSharpWebContext(DbContextOptions DataProtectionKeys { get; set; } = null!; public DbSet McpApiTokens { get; set; } = null!; - public DbSet ReadingActivities { get; set; } = null!; public DbSet UserReadingProfiles { get; set; } = null!; - - protected override void OnModelCreating(ModelBuilder builder) - { - base.OnModelCreating(builder); - - builder.Entity(entity => - { - entity.HasKey(e => e.UserId); - entity.HasOne(e => e.User) - .WithOne() - .HasForeignKey(e => e.UserId) - .OnDelete(DeleteBehavior.Cascade); - }); - - builder.Entity(entity => - { - entity.HasOne(e => e.User) - .WithMany() - .HasForeignKey(e => e.UserId) - .OnDelete(DeleteBehavior.Cascade); - }); - } } diff --git a/EssentialCSharp.Web/Controllers/BookStatsResponse.cs b/EssentialCSharp.Web/Controllers/BookStatsResponse.cs new file mode 100644 index 00000000..9d94e559 --- /dev/null +++ b/EssentialCSharp.Web/Controllers/BookStatsResponse.cs @@ -0,0 +1,5 @@ +namespace EssentialCSharp.Web.Controllers; + +public sealed record ChapterStatsResponse(int ChapterNumber, int WordCount); + +public sealed record BookStatsResponse(int TotalWordCount, IReadOnlyList Chapters); diff --git a/EssentialCSharp.Web/Controllers/ReadingController.cs b/EssentialCSharp.Web/Controllers/ReadingController.cs index 4175cd57..9ed90226 100644 --- a/EssentialCSharp.Web/Controllers/ReadingController.cs +++ b/EssentialCSharp.Web/Controllers/ReadingController.cs @@ -1,299 +1,112 @@ -using System.ComponentModel.DataAnnotations; using System.Security.Claims; -using EssentialCSharp.Web.Data; -using EssentialCSharp.Web.Models; using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Options; namespace EssentialCSharp.Web.Controllers; [ApiController] +[Authorize] [Route("api/reading")] -public partial class ReadingController( - EssentialCSharpWebContext context, +public class ReadingController( + IReadingProfileService readingProfileService, IWordCountService wordCountService, - ILogger logger) : ControllerBase + IOptions options) : ControllerBase { - // Algorithm constants (mirrors Kindle's ReadingTimer values). - private const int MaxWpmHardCutoff = 900; - private const double SlowOutlierFactor = 0.25; - private const int MaxReadingActivityRowsPerUser = 500; - private const int MaxSessionIntervals = 50; - private const int MaxSessionRequestBytes = 32 * 1024; - - // High-performance logger messages (CA1848). - [LoggerMessage(Level = LogLevel.Debug, Message = "Discarding interval for {PageKey}: {Wpm:F0} WPM exceeds hard cutoff of {Cutoff}")] - private static partial void LogIntervalDiscarded(ILogger logger, string pageKey, double wpm, int cutoff); - - [LoggerMessage(Level = LogLevel.Debug, Message = "Clamping slow interval for {PageKey}: {Wpm:F0} WPM → effective {EffSeconds}s")] - private static partial void LogIntervalClamped(ILogger logger, string pageKey, double wpm, int effSeconds); + public const string WriteRateLimitPolicy = "ReadingWrite"; + public const string PublicRateLimitPolicy = "ReadingPublic"; - // ------------------------------------------------------------------------- - // GET /api/reading/book-stats (public — used by anonymous clients too) - // ------------------------------------------------------------------------- + private const int MaxSessionRequestBytes = 32 * 1024; [HttpGet("book-stats")] - public IActionResult GetBookStats() + [AllowAnonymous] + [EnableRateLimiting(PublicRateLimitPolicy)] + // Word counts are computed once at startup, so the response only changes on deploy. + [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetBookStats() { - var chapters = wordCountService.GetChapterWordCounts() - .Select(c => new { chapterNumber = c.ChapterNumber, wordCount = c.WordCount }); + ChapterStatsResponse[] chapters = wordCountService.GetChapterWordCounts() + .Select(c => new ChapterStatsResponse(c.ChapterNumber, c.WordCount)) + .ToArray(); - return Ok(new - { - totalWordCount = wordCountService.GetBookWordCount(), - chapters - }); + return new BookStatsResponse(wordCountService.GetBookWordCount(), chapters); } - // ------------------------------------------------------------------------- - // GET /api/reading/profile (authenticated) - // ------------------------------------------------------------------------- - [HttpGet("profile")] - [Authorize] - public async Task GetProfile(CancellationToken cancellationToken) + [EnableRateLimiting(WriteRateLimitPolicy)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> GetProfile(CancellationToken cancellationToken) { - string userId = GetUserId(); - - UserReadingProfile? profile = await context.UserReadingProfiles - .FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken); - - if (profile is null) + if (GetUserId() is not { } userId) { - return Ok(new { totalWordsRead = 0L, totalActiveSeconds = 0L, wpm = (double?)null }); + return Unauthorized(); } - return Ok(new - { - totalWordsRead = profile.TotalWordsRead, - totalActiveSeconds = profile.TotalActiveSeconds, - wpm = profile.DeriveWpm() - }); + return await readingProfileService.GetProfileAsync(userId, cancellationToken); } - // ------------------------------------------------------------------------- - // POST /api/reading/session (authenticated) - // ------------------------------------------------------------------------- - - public record ReadingIntervalDto( - [Required, StringLength(ReadingActivity.PageKeyMaxLength)] string PageKey, - int ActiveSeconds, - int WordsRead, - bool Completed); - [HttpPost("session")] - [Authorize] + [EnableRateLimiting(WriteRateLimitPolicy)] [RequestSizeLimit(MaxSessionRequestBytes)] - public async Task PostSession( - [FromBody] IEnumerable intervals, + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> PostSession( + [FromBody] ReadingSessionRequest request, CancellationToken cancellationToken) { - string userId = GetUserId(); - - if (intervals is null) + if (GetUserId() is not { } userId) { - return BadRequest("intervals is required"); + return Unauthorized(); } - List intervalList = intervals.Take(MaxSessionIntervals + 1).ToList(); - if (intervalList.Count > MaxSessionIntervals) + int maxIntervals = options.Value.MaxIntervalsPerRequest; + if (request.Intervals.Count > maxIntervals) { - return BadRequest($"A session may contain at most {MaxSessionIntervals} intervals."); + ModelState.AddModelError( + nameof(ReadingSessionRequest.Intervals), + $"A session may contain at most {maxIntervals} intervals."); + return ValidationProblem(ModelState); } - if (intervalList.Count == 0) + if (request.Intervals.Any(i => i is null)) { - return Ok(); + ModelState.AddModelError(nameof(ReadingSessionRequest.Intervals), "Intervals must not contain null entries."); + return ValidationProblem(ModelState); } - // This read supplies the clamping reference; aggregate writes below use atomic SQL increments. - UserReadingProfile? profile = await context.UserReadingProfiles - .AsNoTracking() - .FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken); - double referenceWpm = profile?.DeriveWpm() ?? 0; - - long deltaWords = 0; - long deltaSeconds = 0; - var activities = new List(intervalList.Count); + ReadingInterval[] intervals = request.Intervals + .Select(i => new ReadingInterval(i.PageKey, i.WordsRead, i.ActiveSeconds)) + .ToArray(); - foreach (ReadingIntervalDto interval in intervalList) + RecordSessionResult result = await readingProfileService.RecordSessionAsync(userId, intervals, cancellationToken); + if (result.Profile is null) { - if (interval.ActiveSeconds <= 0 || interval.WordsRead <= 0) - { - continue; - } - - double intervalWpm = interval.WordsRead / (interval.ActiveSeconds / 60.0); - - if (intervalWpm > MaxWpmHardCutoff) - { - LogIntervalDiscarded(logger, interval.PageKey, intervalWpm, MaxWpmHardCutoff); - continue; - } - - int effectiveWords = interval.WordsRead; - int effectiveSeconds = interval.ActiveSeconds; - if (referenceWpm > 0 && intervalWpm < SlowOutlierFactor * referenceWpm) - { - effectiveSeconds = Math.Max( - 1, - (int)Math.Ceiling(effectiveWords / (SlowOutlierFactor * referenceWpm) * 60.0)); - LogIntervalClamped(logger, interval.PageKey, intervalWpm, effectiveSeconds); - } - - deltaWords += effectiveWords; - deltaSeconds += effectiveSeconds; - - activities.Add(new ReadingActivity - { - UserId = userId, - PageKey = interval.PageKey, - RecordedAtUtc = DateTime.UtcNow, - ActiveSeconds = interval.ActiveSeconds, - WordsRead = interval.WordsRead, - Completed = interval.Completed - }); + ModelState.AddModelError(nameof(ReadingIntervalRequest.PageKey), $"Unknown page key '{result.UnknownPageKey}'."); + return ValidationProblem(ModelState); } - profile = await PersistSessionAsync(userId, deltaWords, deltaSeconds, activities, cancellationToken); - return Ok(new - { - totalWordsRead = profile?.TotalWordsRead ?? 0, - totalActiveSeconds = profile?.TotalActiveSeconds ?? 0, - wpm = profile?.DeriveWpm() - }); + return result.Profile; } - private async Task PersistSessionAsync( - string userId, - long deltaWords, - long deltaSeconds, - List activities, - CancellationToken cancellationToken) - { - for (int attempt = 0; attempt < 2; attempt++) - { - await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken); - UserReadingProfile? newProfile = null; - bool attemptedProfileInsert = false; - - try - { - if (deltaWords > 0 || deltaSeconds > 0) - { - int updatedProfiles = await context.UserReadingProfiles - .Where(p => p.UserId == userId) - .ExecuteUpdateAsync( - setters => setters - .SetProperty(p => p.TotalWordsRead, p => p.TotalWordsRead + deltaWords) - .SetProperty(p => p.TotalActiveSeconds, p => p.TotalActiveSeconds + deltaSeconds) - .SetProperty(p => p.UpdatedAtUtc, DateTime.UtcNow), - cancellationToken); - - if (updatedProfiles == 0) - { - attemptedProfileInsert = true; - newProfile = new UserReadingProfile - { - UserId = userId, - TotalWordsRead = deltaWords, - TotalActiveSeconds = deltaSeconds, - UpdatedAtUtc = DateTime.UtcNow - }; - context.UserReadingProfiles.Add(newProfile); - await context.SaveChangesAsync(cancellationToken); - } - } - - if (activities.Count > 0) - { - await context.ReadingActivities.AddRangeAsync(activities, cancellationToken); - } - - await context.SaveChangesAsync(cancellationToken); - await TrimReadingActivitiesAsync(userId, cancellationToken); - await transaction.CommitAsync(cancellationToken); - - return await context.UserReadingProfiles - .AsNoTracking() - .FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken); - } - catch (DbUpdateException) when (attempt == 0 && attemptedProfileInsert) - { - await transaction.RollbackAsync(cancellationToken); - context.Entry(newProfile!).State = EntityState.Detached; - - // A concurrent first request may have inserted this user's profile. - bool profileCreatedConcurrently = await context.UserReadingProfiles - .AnyAsync(p => p.UserId == userId, cancellationToken); - if (!profileCreatedConcurrently) - { - throw; - } - } - catch - { - await transaction.RollbackAsync(cancellationToken); - throw; - } - } - - throw new InvalidOperationException("Unable to persist the reading session after a concurrent profile insert."); - } - - private async Task TrimReadingActivitiesAsync(string userId, CancellationToken cancellationToken) - { - IQueryable retainedActivityIds = context.ReadingActivities - .Where(activity => activity.UserId == userId) - .OrderByDescending(activity => activity.RecordedAtUtc) - .ThenByDescending(activity => activity.Id) - .Take(MaxReadingActivityRowsPerUser) - .Select(activity => activity.Id); - - await context.ReadingActivities - .Where(activity => - activity.UserId == userId && - !retainedActivityIds.Contains(activity.Id)) - .ExecuteDeleteAsync(cancellationToken); - } - - // ------------------------------------------------------------------------- - // POST /api/reading/reset (authenticated) - // ------------------------------------------------------------------------- - [HttpPost("reset")] - [Authorize] + [EnableRateLimiting(WriteRateLimitPolicy)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task Reset(CancellationToken cancellationToken) { - string userId = GetUserId(); - - await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken); - try + if (GetUserId() is not { } userId) { - await context.ReadingActivities - .Where(a => a.UserId == userId) - .ExecuteDeleteAsync(cancellationToken); - - await context.UserReadingProfiles - .Where(p => p.UserId == userId) - .ExecuteDeleteAsync(cancellationToken); - - await transaction.CommitAsync(cancellationToken); - } - catch - { - await transaction.RollbackAsync(cancellationToken); - throw; + return Unauthorized(); } - return Ok(); + await readingProfileService.ResetAsync(userId, cancellationToken); + return NoContent(); } - // ------------------------------------------------------------------------- - - private string GetUserId() => - User.FindFirstValue(ClaimTypes.NameIdentifier) - ?? throw new InvalidOperationException("Authenticated user has no NameIdentifier claim."); + private string? GetUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier); } diff --git a/EssentialCSharp.Web/Controllers/ReadingIntervalRequest.cs b/EssentialCSharp.Web/Controllers/ReadingIntervalRequest.cs new file mode 100644 index 00000000..b02c7233 --- /dev/null +++ b/EssentialCSharp.Web/Controllers/ReadingIntervalRequest.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace EssentialCSharp.Web.Controllers; + +public sealed class ReadingIntervalRequest +{ + public const int PageKeyMaxLength = 256; + public const int MaxActiveSeconds = 24 * 60 * 60; + public const int MaxWordsRead = 100_000; + + /// The page's site-mapping key. + [Required] + [StringLength(PageKeyMaxLength, MinimumLength = 1)] + public string PageKey { get; set; } = string.Empty; + + /// Active (non-idle, visible) seconds spent on the page. + [Range(1, MaxActiveSeconds)] + public int ActiveSeconds { get; set; } + + /// Prose words read during the interval. + [Range(1, MaxWordsRead)] + public int WordsRead { get; set; } + + /// Whether the reader reached the end of the page. Currently informational only. + public bool Completed { get; set; } +} diff --git a/EssentialCSharp.Web/Controllers/ReadingSessionRequest.cs b/EssentialCSharp.Web/Controllers/ReadingSessionRequest.cs new file mode 100644 index 00000000..755ba73b --- /dev/null +++ b/EssentialCSharp.Web/Controllers/ReadingSessionRequest.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace EssentialCSharp.Web.Controllers; + +public sealed class ReadingSessionRequest +{ + /// + /// Reading intervals to record. The upper bound is configured by + /// ReadingOptions.MaxIntervalsPerRequest and enforced by the controller. + /// + [Required] + [MinLength(1)] + public List Intervals { get; set; } = []; +} diff --git a/EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.cs b/EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.cs deleted file mode 100644 index 48686a89..00000000 --- a/EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable -#pragma warning disable CA1861 - -namespace EssentialCSharp.Web.Migrations -{ - /// - public partial class AddReadingTimeTracking : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "ReadingActivities", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - UserId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), - PageKey = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), - RecordedAtUtc = table.Column(type: "datetime2", nullable: false), - ActiveSeconds = table.Column(type: "int", nullable: false), - WordsRead = table.Column(type: "int", nullable: false), - Completed = table.Column(type: "bit", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_ReadingActivities", x => x.Id); - table.ForeignKey( - name: "FK_ReadingActivities_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "UserReadingProfiles", - columns: table => new - { - UserId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), - TotalWordsRead = table.Column(type: "bigint", nullable: false), - TotalActiveSeconds = table.Column(type: "bigint", nullable: false), - UpdatedAtUtc = table.Column(type: "datetime2", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_UserReadingProfiles", x => x.UserId); - table.ForeignKey( - name: "FK_UserReadingProfiles_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_ReadingActivities_UserId_PageKey", - table: "ReadingActivities", - columns: new[] { "UserId", "PageKey" }); - - migrationBuilder.CreateIndex( - name: "IX_ReadingActivities_UserId_RecordedAtUtc", - table: "ReadingActivities", - columns: new[] { "UserId", "RecordedAtUtc" }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "ReadingActivities"); - - migrationBuilder.DropTable( - name: "UserReadingProfiles"); - } - } -} diff --git a/EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.Designer.cs b/EssentialCSharp.Web/Migrations/20261106050505_AddUserReadingProfile.Designer.cs similarity index 87% rename from EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.Designer.cs rename to EssentialCSharp.Web/Migrations/20261106050505_AddUserReadingProfile.Designer.cs index be4d7218..40cee064 100644 --- a/EssentialCSharp.Web/Migrations/20260809064123_AddReadingTimeTracking.Designer.cs +++ b/EssentialCSharp.Web/Migrations/20261106050505_AddUserReadingProfile.Designer.cs @@ -12,15 +12,15 @@ namespace EssentialCSharp.Web.Migrations { [DbContext(typeof(EssentialCSharpWebContext))] - [Migration("20260809064123_AddReadingTimeTracking")] - partial class AddReadingTimeTracking + [Migration("20261106050505_AddUserReadingProfile")] + partial class AddUserReadingProfile { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("ProductVersion", "10.0.14") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -144,45 +144,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("McpApiTokens"); }); - modelBuilder.Entity("EssentialCSharp.Web.Models.ReadingActivity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ActiveSeconds") - .HasColumnType("int"); - - b.Property("Completed") - .HasColumnType("bit"); - - b.Property("PageKey") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("RecordedAtUtc") - .HasColumnType("datetime2"); - - b.Property("UserId") - .IsRequired() - .HasMaxLength(450) - .HasColumnType("nvarchar(450)"); - - b.Property("WordsRead") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("UserId", "PageKey"); - - b.HasIndex("UserId", "RecordedAtUtc"); - - b.ToTable("ReadingActivities"); - }); - modelBuilder.Entity("EssentialCSharp.Web.Models.UserReadingProfile", b => { b.Property("UserId") @@ -370,7 +331,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("EssentialCSharp.Web.Models.ReadingActivity", b => + modelBuilder.Entity("EssentialCSharp.Web.Models.UserReadingProfile", b => { b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", "User") .WithMany() @@ -381,17 +342,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("EssentialCSharp.Web.Models.UserReadingProfile", b => - { - b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", "User") - .WithOne() - .HasForeignKey("EssentialCSharp.Web.Models.UserReadingProfile", "UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) diff --git a/EssentialCSharp.Web/Migrations/20261106050505_AddUserReadingProfile.cs b/EssentialCSharp.Web/Migrations/20261106050505_AddUserReadingProfile.cs new file mode 100644 index 00000000..f94e245e --- /dev/null +++ b/EssentialCSharp.Web/Migrations/20261106050505_AddUserReadingProfile.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EssentialCSharp.Web.Migrations +{ + /// + public partial class AddUserReadingProfile : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserReadingProfiles", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + TotalWordsRead = table.Column(type: "bigint", nullable: false), + TotalActiveSeconds = table.Column(type: "bigint", nullable: false), + UpdatedAtUtc = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserReadingProfiles", x => x.UserId); + table.ForeignKey( + name: "FK_UserReadingProfiles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserReadingProfiles"); + } + } +} diff --git a/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs b/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs index 2d6c64af..edc3d757 100644 --- a/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs +++ b/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("ProductVersion", "10.0.14") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -141,45 +141,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("McpApiTokens"); }); - modelBuilder.Entity("EssentialCSharp.Web.Models.ReadingActivity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ActiveSeconds") - .HasColumnType("int"); - - b.Property("Completed") - .HasColumnType("bit"); - - b.Property("PageKey") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("RecordedAtUtc") - .HasColumnType("datetime2"); - - b.Property("UserId") - .IsRequired() - .HasMaxLength(450) - .HasColumnType("nvarchar(450)"); - - b.Property("WordsRead") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("UserId", "PageKey"); - - b.HasIndex("UserId", "RecordedAtUtc"); - - b.ToTable("ReadingActivities"); - }); - modelBuilder.Entity("EssentialCSharp.Web.Models.UserReadingProfile", b => { b.Property("UserId") @@ -367,7 +328,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("EssentialCSharp.Web.Models.ReadingActivity", b => + modelBuilder.Entity("EssentialCSharp.Web.Models.UserReadingProfile", b => { b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", "User") .WithMany() @@ -378,17 +339,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("EssentialCSharp.Web.Models.UserReadingProfile", b => - { - b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", "User") - .WithOne() - .HasForeignKey("EssentialCSharp.Web.Models.UserReadingProfile", "UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) diff --git a/EssentialCSharp.Web/Models/ReadingActivity.cs b/EssentialCSharp.Web/Models/ReadingActivity.cs deleted file mode 100644 index 3ff0ac81..00000000 --- a/EssentialCSharp.Web/Models/ReadingActivity.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using EssentialCSharp.Web.Areas.Identity.Data; -using Microsoft.EntityFrameworkCore; - -namespace EssentialCSharp.Web.Models; - -/// -/// Records a single reading interval for a content page. -/// This is an audit/detail trail; the WPM math reads only -/// . -/// -[Index(nameof(UserId), nameof(RecordedAtUtc))] -[Index(nameof(UserId), nameof(PageKey))] -public class ReadingActivity -{ - public const int PageKeyMaxLength = 256; - - public int Id { get; set; } - - [Required] - [MaxLength(450)] - public required string UserId { get; set; } - - /// - /// Matches for the page that was read. - /// - [Required] - [MaxLength(PageKeyMaxLength)] - public required string PageKey { get; set; } - - public DateTime RecordedAtUtc { get; set; } = DateTime.UtcNow; - - /// Active (non-idle, non-hidden) seconds spent on this page. - public int ActiveSeconds { get; set; } - - /// Prose words credited to this reading interval (may be fractional for partial reads, rounded to int). - public int WordsRead { get; set; } - - /// True when the reader reached the bottom of the page content. - public bool Completed { get; set; } - - public EssentialCSharpWebUser? User { get; set; } -} diff --git a/EssentialCSharp.Web/Models/UserReadingProfile.cs b/EssentialCSharp.Web/Models/UserReadingProfile.cs index 298d4021..c618a18d 100644 --- a/EssentialCSharp.Web/Models/UserReadingProfile.cs +++ b/EssentialCSharp.Web/Models/UserReadingProfile.cs @@ -1,5 +1,7 @@ using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; using EssentialCSharp.Web.Areas.Identity.Data; +using Microsoft.EntityFrameworkCore; namespace EssentialCSharp.Web.Models; @@ -8,30 +10,27 @@ namespace EssentialCSharp.Web.Models; /// WPM is always derived as TotalWordsRead / (TotalActiveSeconds / 60.0); /// it is never stored directly so there is no floating-point drift over time. /// +[PrimaryKey(nameof(UserId))] public class UserReadingProfile { - /// Primary key — also the FK to . - [Required] + /// Primary key; also the FK to . [MaxLength(450)] public required string UserId { get; set; } - /// - /// Cumulative prose words credited across all accepted, clamped reading intervals. - /// + /// Cumulative prose words credited across all accepted reading intervals. public long TotalWordsRead { get; set; } - /// - /// Cumulative active seconds across all accepted, clamped reading intervals. - /// + /// Cumulative active seconds credited across all accepted reading intervals. public long TotalActiveSeconds { get; set; } - public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAtUtc { get; set; } + [ForeignKey(nameof(UserId))] public EssentialCSharpWebUser? User { get; set; } - /// - /// Derives the current words-per-minute estimate. Returns null if insufficient data. - /// - public double? DeriveWpm() => - TotalActiveSeconds > 0 ? TotalWordsRead / (TotalActiveSeconds / 60.0) : null; + /// Derives the words-per-minute estimate. Returns null if there is no active time. + public double? DeriveWpm() => DeriveWpm(TotalWordsRead, TotalActiveSeconds); + + public static double? DeriveWpm(long totalWordsRead, long totalActiveSeconds) => + totalActiveSeconds > 0 ? totalWordsRead / (totalActiveSeconds / 60.0) : null; } diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 5c7fd33f..2ecfc04d 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -1,707 +1,734 @@ -using System.Security.Claims; -using System.Runtime.InteropServices; -using System.Threading.RateLimiting; -using ModelContextProtocol.Protocol; -using EssentialCSharp.Chat.Common.Extensions; -using EssentialCSharp.Web.Areas.Identity.Data; -using EssentialCSharp.Web.Areas.Identity.Services.PasswordValidators; -using EssentialCSharp.Web.Auth; -using EssentialCSharp.Web.Data; -using EssentialCSharp.Web.Extensions; -using EssentialCSharp.Web.Helpers; -using EssentialCSharp.Web.Middleware; -using EssentialCSharp.Web.Services; -using EssentialCSharp.Web.Services.Referrals; -using EssentialCSharp.Web.Tools; -using Mailjet.Client; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Identity.UI.Services; -using Microsoft.AspNetCore.RateLimiting; -using Azure.Monitor.OpenTelemetry.AspNetCore; -using Azure.Monitor.OpenTelemetry.Profiler; -using Microsoft.AspNetCore.DataProtection; -using Microsoft.AspNetCore.Diagnostics; -using Microsoft.AspNetCore.Diagnostics.HealthChecks; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using OpenTelemetry; -using OpenTelemetry.Instrumentation.AspNetCore; -using OpenTelemetry.Metrics; -using OpenTelemetry.Trace; - -namespace EssentialCSharp.Web; - -public partial class Program -{ - private static void Main(string[] args) - { - WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - - // Health checks (liveness/readiness probes for ACA and standalone hosting) - builder.Services.AddHealthChecks() - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); - - // Prefer Aspire's OTLP endpoint when present; otherwise export to Azure Monitor. - // This keeps local traces in the dashboard even when a developer has an App Insights connection string configured. - string? appInsightsConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]; - bool useOtlp = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); - bool useAzureMonitor = !useOtlp && !string.IsNullOrWhiteSpace(appInsightsConnectionString); - bool profilerSupportedPlatform = OperatingSystem.IsWindows() || OperatingSystem.IsLinux(); - bool profilerSkippedUnsupportedPlatform = false; - - builder.Logging.AddOpenTelemetry(logging => - { - logging.IncludeFormattedMessage = true; - logging.IncludeScopes = true; - }); - - // Health probe paths excluded from tracing unconditionally — applies to both - // manual instrumentation and Azure Monitor's auto-instrumentation. - builder.Services.Configure(options => - { - options.Filter = ctx => - !ctx.Request.Path.StartsWithSegments("/health") - && !ctx.Request.Path.StartsWithSegments("/alive"); - // EnrichWithHttpResponse fires after the authentication middleware has run, - // so HttpContext.User is populated and IsAuthenticated is reliable. - options.EnrichWithHttpResponse = (activity, response) => - { - var user = response.HttpContext.User; - if (user?.Identity?.IsAuthenticated != true) - { - return; - } - - string? userId = user.FindFirstValue(ClaimTypes.NameIdentifier); - if (!string.IsNullOrWhiteSpace(userId)) - { - activity.SetTag("enduser.id", userId); - } - }; - }); - - var otel = builder.Services.AddOpenTelemetry() - .WithMetrics(metrics => - { - // Azure Monitor auto-instruments ASP.NET Core + HttpClient metrics; only add - // them manually when using OTLP so we don't register duplicate meter listeners. - if (!useAzureMonitor) - { - metrics.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation(); - } - // Runtime metrics are not included in the Azure Monitor distro. - metrics.AddRuntimeInstrumentation(); - }) - .WithTracing(tracing => - { - tracing.AddSource(builder.Environment.ApplicationName); - // Azure Monitor distro auto-instruments tracing; add manually only for OTLP path. - if (!useAzureMonitor) - { - tracing.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddSqlClientInstrumentation(); - } - }); - - if (useAzureMonitor) - { - // Azure Monitor export is supported cross-platform, but the profiler currently only - // supports Windows and Linux. - var azureMonitor = otel.UseAzureMonitor(); - if (profilerSupportedPlatform) - azureMonitor.AddAzureMonitorProfiler(); - else - profilerSkippedUnsupportedPlatform = true; - } - else if (useOtlp) - otel.UseOtlpExporter(); - - // HttpClient defaults — standard retry/circuit breaker for all named clients. - builder.Services.ConfigureHttpClientDefaults(http => http.AddStandardResilienceHandler()); - - builder.Services.AddHttpClient("HaveIBeenPwned", c => - { - c.BaseAddress = new Uri("https://api.pwnedpasswords.com/"); - c.DefaultRequestHeaders.UserAgent.ParseAdd("EssentialCSharp.Web/1.0"); - // Short timeout: this check is advisory/fail-open, so cap latency impact on auth flows. - c.Timeout = TimeSpan.FromSeconds(3); - }); - - builder.Services.AddTrustedForwardedHeaders(builder.Configuration, builder.Environment); - builder.Services.AddHttpsRedirection(options => - { - options.HttpsPort = 443; - }); - - ConfigurationManager configuration = builder.Configuration; - string connectionString = builder.Configuration.GetConnectionString("EssentialCSharpWebContextConnection") ?? throw new InvalidOperationException("Connection string 'EssentialCSharpWebContextConnection' not found."); - - builder.Services.AddDbContext(options => options.UseSqlServer(connectionString, sql => sql.EnableRetryOnFailure(5))); - - if (builder.Environment.IsDevelopment()) - { - // Must run before DataProtectionHostedService, which reads DataProtectionKeys. - builder.Services.AddHostedService(); - } - - // Data Protection — persist keys to SQL Server so they survive container restarts. - // SetApplicationName ensures the discriminator is stable across container hostname changes. - var dpBuilder = builder.Services.AddDataProtection() - .SetApplicationName("EssentialCSharpWeb") - .PersistKeysToDbContext(); - var keyVaultKeyUri = builder.Configuration["DataProtection:AzureKeyVaultKeyUri"]; - if (!string.IsNullOrEmpty(keyVaultKeyUri)) - { - dpBuilder.ProtectKeysWithAzureKeyVault(new Uri(keyVaultKeyUri), new Azure.Identity.DefaultAzureCredential()); - } - else if (!builder.Environment.IsDevelopment()) - { - throw new InvalidOperationException( - "DataProtection:AzureKeyVaultKeyUri is required in non-Development environments. " + - "Set the DataProtection__AzureKeyVaultKeyUri environment variable to the Key Vault key URI."); - } - - builder.Services.AddDefaultIdentity(options => - { - // Password settings - options.User.RequireUniqueEmail = true; - options.Password.RequiredLength = PasswordRequirementOptions.PasswordMinimumLength; - options.Password.RequireDigit = false; - options.Password.RequireNonAlphanumeric = false; - options.Password.RequireUppercase = false; - options.Password.RequireLowercase = false; - options.Password.RequiredUniqueChars = 1; - - options.SignIn.RequireConfirmedEmail = true; - options.SignIn.RequireConfirmedAccount = true; - - options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(30); - options.Lockout.MaxFailedAccessAttempts = 3; - - //TODO: Implement IProtectedUserStore - //options.Stores.ProtectPersonalData = true; - }) - .AddEntityFrameworkStores() - .AddPasswordValidator>() - .AddPasswordValidator>() - .AddPasswordValidator>(); - - builder.Configuration - .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) - .AddUserSecrets() - .AddEnvironmentVariables(); - - builder.Services.ConfigureApplicationCookie(options => - { - options.Cookie.HttpOnly = true; - options.ExpireTimeSpan = TimeSpan.FromMinutes(60); - options.SlidingExpiration = true; - // API endpoints must return 401/403 instead of redirecting to the login page. - // Cookie auth's default behavior (302 redirect) causes fetch() to follow the - // redirect, eventually hitting the fallback controller and returning a 404. - options.Events.OnRedirectToLogin = context => - { - if (context.Request.Path.StartsWithSegments("/api") - || context.Request.Path.StartsWithSegments("/mcp")) - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - else - context.Response.Redirect(context.RedirectUri); - return Task.CompletedTask; - }; - options.Events.OnRedirectToAccessDenied = context => - { - if (context.Request.Path.StartsWithSegments("/api") - || context.Request.Path.StartsWithSegments("/mcp")) - context.Response.StatusCode = StatusCodes.Status403Forbidden; - else - context.Response.Redirect(context.RedirectUri); - return Task.CompletedTask; - }; - }); - - builder.Services.Configure(option => - { - // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 - // Minimum recommended is currently 210,000 iterations for pdkdf2-sha512 as of October 27, 2023 - option.IterationCount = 500000; - }); - builder.Services.AddScoped>(provider => - { - if (!provider.GetRequiredService>().SupportsUserEmail) - { - throw new NotSupportedException("The default UI requires a user store with email support."); - } - return (IUserEmailStore)provider.GetRequiredService>(); - }); - - builder.Services.AddScoped>(provider => - { - if (provider.GetRequiredService>() is IUserPasswordStore userPasswordStore) - { - return userPasswordStore; - } - throw new NotSupportedException("The default UI requires a user store with password support."); - }); - - builder.Services.AddControllersWithViews(options => - { - options.Filters.Add(new Microsoft.AspNetCore.Mvc.AutoValidateAntiforgeryTokenAttribute()); - }); - - if (!builder.Environment.IsDevelopment()) - { - builder.Services.AddTransient(); - } - builder.Services.Configure(builder.Configuration.GetSection(AuthMessageSenderOptions.AuthMessageSender)); - builder.Services.Configure(builder.Configuration.GetSection(SiteSettings.SectionName)); - - // Add services to the container. - builder.Services.AddRazorPages(); - builder.Services.AddOutputCache(); - builder.Services.AddCaptchaService(builder.Configuration.GetSection(CaptchaOptions.CaptchaSender)); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - - // Add AI Chat services using configuration-driven backend selection. - builder.Services.AddConfiguredChatServices(configuration); - - // MCP server — always enabled, authenticated via opaque DB-backed tokens. - builder.Services.AddScoped(); - - builder.Services.AddMemoryCache(); - builder.Services.AddSingleton(_ => new ResponseIdValidationService()); - - builder.Services.AddAuthentication() - .AddScheme( - McpBearerAuthentication.Scheme, _ => { }); - - builder.Services.AddAuthorization(options => - options.AddPolicy("McpPolicy", policy => - policy.AddAuthenticationSchemes(McpBearerAuthentication.Scheme) - .RequireAuthenticatedUser())); - - builder.Services.AddCors(options => - options.AddPolicy("McpInspectorCors", policy => - policy.SetIsOriginAllowed(origin => - Uri.TryCreate(origin, UriKind.Absolute, out Uri? originUri) - && originUri.IsLoopback - && (originUri.Scheme == Uri.UriSchemeHttp || originUri.Scheme == Uri.UriSchemeHttps)) - .AllowAnyHeader() - .AllowAnyMethod() - .WithExposedHeaders("Mcp-Session-Id"))); - - builder.Services.AddSingleton(); - - builder.Services.AddMcpServer() - .WithHttpTransport(options => options.Stateless = true) - .WithTools() - .WithTools() - .WithTools() - .WithTools(); - - // Add Rate Limiting for API endpoints - builder.Services.AddRateLimiter(options => - { - // Global rate limiter for site requests by authenticated user ID or anonymous IP. - // MCP transport requests use a dedicated named policy attached to MapMcp("/mcp"). - options.GlobalLimiter = PartitionedRateLimiter.Create(httpContext => - { - if (httpContext.Request.Path.StartsWithSegments("/.well-known")) - return RateLimitPartition.GetNoLimiter("well-known"); - - if (IsMcpTransportRequest(httpContext.Request)) - return RateLimitPartition.GetNoLimiter("mcp-transport"); - - var partitionKey = httpContext.User.Identity?.IsAuthenticated == true - ? httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown-user" - : httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"; - - return RateLimitPartition.GetFixedWindowLimiter( - partitionKey: partitionKey, - factory: _ => new FixedWindowRateLimiterOptions - { - PermitLimit = 30, // requests per window - Window = TimeSpan.FromMinutes(1), // minute window - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - QueueLimit = 0 // No queuing - immediate rejection for better UX - }); - }); - - options.AddPolicy("ChatEndpoint", httpContext => - { - // Partitioned per-user (when authenticated) or per-IP (anonymous) - var partitionKey = httpContext.User.Identity?.IsAuthenticated == true - ? $"chat-user:{httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown-user"}" - : $"chat-ip:{httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"}"; - - return RateLimitPartition.GetFixedWindowLimiter( - partitionKey: partitionKey, - factory: _ => new FixedWindowRateLimiterOptions - { - PermitLimit = 15, - Window = TimeSpan.FromMinutes(1), - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - QueueLimit = 0 - }); - }); - - // Combined per-minute burst (10/min) + per-hour cap (150/hr) for book content pages. - // A scraper cycling through the full ~400-page book needs 2+ hours at minimum. - // See Services/ContentRateLimiterPolicy.cs for implementation. - options.AddPolicy("content", new ContentRateLimiterPolicy()); - options.AddPolicy(McpRateLimiterPolicy.PolicyName, new McpRateLimiterPolicy()); - - // Custom response when rate limit is exceeded - options.OnRejected = async (context, cancellationToken) => - { - context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; - int? retryAfterSeconds = RateLimitingResponseHelpers.ApplyRetryAfterHeader( - context.HttpContext.Response, - context.Lease); - var logger = context.HttpContext.RequestServices.GetRequiredService>(); - - if (context.HttpContext.Request.Path.StartsWithSegments("/api/chat")) - { - // Custom rejection handling logic - context.HttpContext.Response.ContentType = "application/json"; - - Dictionary errorResponse = new() - { - ["error"] = "Rate limit exceeded. Please wait before sending another message.", - ["statusCode"] = 429 - }; - if (retryAfterSeconds is int retryAfter) - errorResponse["retryAfter"] = retryAfter; - - await context.HttpContext.Response.WriteAsync( - System.Text.Json.JsonSerializer.Serialize(errorResponse), - cancellationToken); - - // Optional logging - LogRateLimitExceeded( - logger, - context.HttpContext.Request.Path, - context.HttpContext.User.Identity?.Name ?? "anonymous", - context.HttpContext.Connection.RemoteIpAddress); - return; - } - - await context.HttpContext.Response.WriteAsync("Rate limit exceeded. Please try again later.", cancellationToken); - - LogRateLimitExceeded( - logger, - context.HttpContext.Request.Path, - context.HttpContext.User.Identity?.Name ?? "anonymous", - context.HttpContext.Connection.RemoteIpAddress); - }; - }); - - if (!builder.Environment.IsDevelopment()) - { - builder.Services.AddHttpClient(client => - { - //set BaseAddress, MediaType, UserAgent - client.SetDefaultSettings(); - - client.UseBasicAuthentication(configuration["AuthMessageSender:APIKey"], configuration["AuthMessageSender:SecretKey"]); - }); - } - - if (!builder.Environment.IsDevelopment()) - { - builder.Services.AddAuthentication() - .AddMicrosoftAccount(microsoftoptions => - { - microsoftoptions.ClientId = configuration["authentication:microsoft:clientid"] ?? throw new InvalidOperationException("authentication:microsoft:clientid unexpectedly null"); - microsoftoptions.ClientSecret = configuration["authentication:microsoft:clientsecret"] ?? throw new InvalidOperationException("authentication:microsoft:clientsecret unexpectedly null"); - }) - .AddGitHub(o => - { - o.ClientId = configuration["authentication:github:clientId"] ?? throw new InvalidOperationException("github:clientId unexpectedly null"); - o.ClientSecret = configuration["authentication:github:clientSecret"] ?? throw new InvalidOperationException("github:clientSecret unexpectedly null"); - - // Grants access to read a user's profile data. - // https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps - o.Scope.Add("read:user"); - }); - } - - WebApplication app = builder.Build(); - - // Build the page word-count cache during startup instead of on the first content request. - _ = app.Services.GetRequiredService(); - - if (profilerSkippedUnsupportedPlatform) - LogSkippingUnsupportedAzureMonitorProfiler( - app.Services.GetRequiredService>(), - RuntimeInformation.OSDescription); - - // Configure the HTTP request pipeline. - if (!app.Environment.IsDevelopment()) - { - SiteSettings siteSettings = app.Services.GetRequiredService>().Value; - if (!Uri.TryCreate(siteSettings.BaseUrl, UriKind.Absolute, out Uri? configuredBaseUri)) - { - throw new InvalidOperationException($"Invalid {SiteSettings.SectionName}:{nameof(SiteSettings.BaseUrl)} value: '{siteSettings.BaseUrl}'."); - } - string apexHost = configuredBaseUri.Host.StartsWith("www.", StringComparison.OrdinalIgnoreCase) - ? configuredBaseUri.Host[4..] - : configuredBaseUri.Host; - string wwwHost = $"www.{apexHost}"; - Uri apexUri = new UriBuilder(configuredBaseUri) { Host = apexHost }.Uri; - string redirectScheme = apexUri.Scheme; - HostString redirectHost = new HostString(apexUri.Authority); - - app.UseExceptionHandler(exceptionApp => - { - exceptionApp.Run(async context => - { - var exceptionFeature = context.Features.Get(); - var logger = context.RequestServices.GetRequiredService>(); - LogUnhandledException(logger, exceptionFeature?.Error, context.Request.Path); - - if (context.Request.Path.StartsWithSegments("/mcp")) - { - await McpJsonRpcResponseWriter.WriteErrorAsync( - context.Response, - StatusCodes.Status500InternalServerError, - -32603, - "An unexpected error occurred while processing the MCP request.", - context.RequestAborted); - } - else if (context.Request.Path.StartsWithSegments("/api")) - { - context.Response.StatusCode = 500; - context.Response.ContentType = "application/json"; - await context.Response.WriteAsJsonAsync(new { error = "An unexpected error occurred" }); - } - else - { - context.Response.Redirect("/Home/Error?statusCode=500"); - } - }); - }); - // Skip manual UseForwardedHeaders when ASPNETCORE_FORWARDEDHEADERS_ENABLED=true; - // the built-in startup filter already called it before this pipeline runs. - if (!string.Equals(app.Configuration["ASPNETCORE_FORWARDEDHEADERS_ENABLED"], "true", StringComparison.OrdinalIgnoreCase)) - { - app.UseForwardedHeaders(); - } - - // Build dynamic CSP — TryDotNet origin comes from runtime config - string? tryDotNetOrigin = app.Configuration["TryDotNet:Origin"]; - string tryDotNetSources = string.Empty; - if (!string.IsNullOrWhiteSpace(tryDotNetOrigin)) - { - if (Uri.TryCreate(tryDotNetOrigin, UriKind.Absolute, out Uri? tryDotNetUri)) - { - tryDotNetSources = $" {tryDotNetUri.GetLeftPart(UriPartial.Authority)}"; - } - else - { - LogIgnoringInvalidTryDotNetOrigin(app.Logger, tryDotNetOrigin); - } - } - - string csp = string.Join("; ", - $"default-src 'self'", - $"script-src 'self' 'unsafe-inline' cdn.jsdelivr.net www.clarity.ms www.googletagmanager.com js.monitor.azure.com https://hcaptcha.com https://*.hcaptcha.com{tryDotNetSources}", - $"style-src 'self' 'nonce-{CspNonce.Placeholder}' cdnjs.cloudflare.com fonts.googleapis.com https://hcaptcha.com https://*.hcaptcha.com", - $"style-src-attr 'unsafe-inline'", - $"font-src 'self' fonts.gstatic.com cdnjs.cloudflare.com", - $"img-src 'self' data: https:", - $"connect-src 'self' https://hcaptcha.com https://*.hcaptcha.com https://api.pwnedpasswords.com https://*.algolia.net https://*.algolianet.com https://*.google-analytics.com https://*.clarity.ms https://*.in.applicationinsights.azure.com{GetApplicationInsightsCspSources(app.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"], app.Logger)}{tryDotNetSources}", - $"frame-src https://hcaptcha.com https://*.hcaptcha.com https://newassets.hcaptcha.com{tryDotNetSources}", - $"worker-src blob:", - $"frame-ancestors 'none'", - $"base-uri 'self'", - $"form-action 'self' https://login.microsoftonline.com https://github.com" - ); - - app.UseSecurityHeadersMiddleware(new SecurityHeadersBuilder() - .AddDefaultSecurePolicy() - .AddContentSecurityPolicy(csp)); - - // Redirect configured www host to configured apex host (permanent 301). - // Must be after UseForwardedHeaders so the Host header reflects the real hostname. - app.Use(async (context, next) => - { - if (string.Equals(context.Request.Host.Host, wwwHost, StringComparison.OrdinalIgnoreCase)) - { - string redirectUrl = UriHelper.BuildAbsolute(redirectScheme, redirectHost, context.Request.PathBase, context.Request.Path, context.Request.QueryString); - context.Response.Redirect(redirectUrl, permanent: true); - return; - } - await next(context); - }); - } - else - { - app.UseDeveloperExceptionPage(); - if (!string.Equals(app.Configuration["ASPNETCORE_FORWARDEDHEADERS_ENABLED"], "true", StringComparison.OrdinalIgnoreCase)) - { - app.UseForwardedHeaders(); - } - } - - app.MapHealthChecks("/health").DisableRateLimiting(); - app.MapHealthChecks("/alive", new HealthCheckOptions - { - Predicate = r => r.Tags.Contains("live") - }).DisableRateLimiting(); - - if (!app.Environment.IsDevelopment()) - { - app.UseHttpsRedirection(); - } - app.UseStaticFiles(); - - app.UseRouting(); - - app.UseWhen( - context => context.Request.Path.StartsWithSegments("/mcp"), - branch => branch.UseCors("McpInspectorCors")); - - app.UseAuthentication(); - - // /mcp uses a named non-default scheme. Normalize the principal before - // rate limiting so valid MCP requests partition by MCP user while - // missing/invalid bearer requests fall back to the anonymous/IP bucket - // instead of inheriting the site's cookie principal. - app.UseWhen( - context => context.Request.Path.StartsWithSegments("/mcp"), - branch => branch.UseMiddleware()); - - app.UseRateLimiter(); - - app.UseAuthorization(); - app.UseOutputCache(); - - app.UseMiddleware(); - - app.MapRazorPages(); - app.MapDefaultControllerRoute(); - - app.MapMethods("/mcp", [HttpMethods.Get], (HttpResponse response) => - { - response.Headers.Append("Allow", HttpMethods.Post); - response.Headers.CacheControl = "no-store"; - return Results.StatusCode(StatusCodes.Status405MethodNotAllowed); - }); - - app.MapMcp("/mcp") - .RequireAuthorization("McpPolicy") - .RequireRateLimiting(McpRateLimiterPolicy.PolicyName); - - app.Map("/.well-known", (HttpResponse response) => - { - response.Headers.CacheControl = "no-store"; - return Results.NotFound(); - }).DisableRateLimiting(); - - app.Map("/.well-known/{**path}", (HttpResponse response) => - { - response.Headers.CacheControl = "no-store"; - return Results.NotFound(); - }).DisableRateLimiting(); - - app.MapFallbackToController("Index", "Home"); - - // Validate sitemap data at startup — logs errors but allows startup to continue - var siteMappingService = app.Services.GetRequiredService(); - var logger = app.Services.GetRequiredService>(); - - try - { - SitemapXmlHelpers.EnsureSitemapHealthy(siteMappingService.SiteMappings.ToList()); - LogSitemapValidationSucceeded(logger); - } - catch (InvalidOperationException ex) - { - LogSitemapValidationFailed(logger, ex); - // Continue startup even if sitemap validation fails - } - - app.Run(); - } - - private static bool IsMcpTransportRequest(HttpRequest request) => - HttpMethods.IsPost(request.Method) && request.Path == "/mcp"; - - [LoggerMessage(Level = LogLevel.Warning, Message = "Rate limit exceeded on {Path}. User: {User}, IP: {IpAddress}")] - private static partial void LogRateLimitExceeded(ILogger logger, PathString path, string user, System.Net.IPAddress? ipAddress); - - [LoggerMessage(Level = LogLevel.Error, Message = "Unhandled exception on {Path}")] - private static partial void LogUnhandledException(ILogger logger, Exception? exception, PathString path); - - [LoggerMessage(Level = LogLevel.Information, Message = "Sitemap validation completed successfully during application startup")] - private static partial void LogSitemapValidationSucceeded(ILogger logger); - - [LoggerMessage(Level = LogLevel.Error, Message = "Failed to validate sitemap during application startup")] - private static partial void LogSitemapValidationFailed(ILogger logger, Exception exception); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Ignoring invalid TryDotNet origin in CSP: {Origin}")] - private static partial void LogIgnoringInvalidTryDotNetOrigin(ILogger logger, string origin); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Azure Monitor profiler is not supported on this platform ({Platform}). Skipping profiler registration and continuing with Azure Monitor telemetry export.")] - private static partial void LogSkippingUnsupportedAzureMonitorProfiler(ILogger logger, string platform); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Application Insights connection string has a non-HTTPS or unparseable IngestionEndpoint value ({Endpoint}); omitting from CSP connect-src.")] - private static partial void LogInvalidApplicationInsightsIngestionEndpoint(ILogger logger, string? endpoint); - - private static string GetApplicationInsightsCspSources(string? connectionString, ILogger? logger = null) - { - if (string.IsNullOrWhiteSpace(connectionString)) - { - return string.Empty; - } - - string? ingestionEndpoint = GetConnectionStringValue(connectionString, "IngestionEndpoint"); - if (string.IsNullOrWhiteSpace(ingestionEndpoint) - || !Uri.TryCreate(ingestionEndpoint, UriKind.Absolute, out Uri? ingestionUri) - || ingestionUri.Scheme != Uri.UriSchemeHttps) - { - if (logger is not null) - { - LogInvalidApplicationInsightsIngestionEndpoint(logger, ingestionEndpoint); - } - return string.Empty; - } - - return $" {ingestionUri.GetLeftPart(UriPartial.Authority)}"; - } - - private static string? GetConnectionStringValue(string connectionString, string key) - { - foreach (string segment in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - int separatorIndex = segment.IndexOf('='); - if (separatorIndex <= 0) - { - continue; - } - - string currentKey = segment[..separatorIndex]; - if (!currentKey.Equals(key, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - return segment[(separatorIndex + 1)..].Trim('"'); - } - - return null; - } -} +using System.Security.Claims; +using System.Runtime.InteropServices; +using System.Threading.RateLimiting; +using ModelContextProtocol.Protocol; +using EssentialCSharp.Chat.Common.Extensions; +using EssentialCSharp.Web.Areas.Identity.Data; +using EssentialCSharp.Web.Areas.Identity.Services.PasswordValidators; +using EssentialCSharp.Web.Auth; +using EssentialCSharp.Web.Controllers; +using EssentialCSharp.Web.Data; +using EssentialCSharp.Web.Extensions; +using EssentialCSharp.Web.Helpers; +using EssentialCSharp.Web.Middleware; +using EssentialCSharp.Web.Services; +using EssentialCSharp.Web.Services.Referrals; +using EssentialCSharp.Web.Tools; +using Mailjet.Client; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.RateLimiting; +using Azure.Monitor.OpenTelemetry.AspNetCore; +using Azure.Monitor.OpenTelemetry.Profiler; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenTelemetry; +using OpenTelemetry.Instrumentation.AspNetCore; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace EssentialCSharp.Web; + +public partial class Program +{ + private static void Main(string[] args) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + + // Health checks (liveness/readiness probes for ACA and standalone hosting) + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + // Prefer Aspire's OTLP endpoint when present; otherwise export to Azure Monitor. + // This keeps local traces in the dashboard even when a developer has an App Insights connection string configured. + string? appInsightsConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]; + bool useOtlp = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + bool useAzureMonitor = !useOtlp && !string.IsNullOrWhiteSpace(appInsightsConnectionString); + bool profilerSupportedPlatform = OperatingSystem.IsWindows() || OperatingSystem.IsLinux(); + bool profilerSkippedUnsupportedPlatform = false; + + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + // Health probe paths excluded from tracing unconditionally — applies to both + // manual instrumentation and Azure Monitor's auto-instrumentation. + builder.Services.Configure(options => + { + options.Filter = ctx => + !ctx.Request.Path.StartsWithSegments("/health") + && !ctx.Request.Path.StartsWithSegments("/alive"); + // EnrichWithHttpResponse fires after the authentication middleware has run, + // so HttpContext.User is populated and IsAuthenticated is reliable. + options.EnrichWithHttpResponse = (activity, response) => + { + var user = response.HttpContext.User; + if (user?.Identity?.IsAuthenticated != true) + { + return; + } + + string? userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + if (!string.IsNullOrWhiteSpace(userId)) + { + activity.SetTag("enduser.id", userId); + } + }; + }); + + var otel = builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + // Azure Monitor auto-instruments ASP.NET Core + HttpClient metrics; only add + // them manually when using OTLP so we don't register duplicate meter listeners. + if (!useAzureMonitor) + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation(); + } + // Runtime metrics are not included in the Azure Monitor distro. + metrics.AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName); + // Azure Monitor distro auto-instruments tracing; add manually only for OTLP path. + if (!useAzureMonitor) + { + tracing.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddSqlClientInstrumentation(); + } + }); + + if (useAzureMonitor) + { + // Azure Monitor export is supported cross-platform, but the profiler currently only + // supports Windows and Linux. + var azureMonitor = otel.UseAzureMonitor(); + if (profilerSupportedPlatform) + azureMonitor.AddAzureMonitorProfiler(); + else + profilerSkippedUnsupportedPlatform = true; + } + else if (useOtlp) + otel.UseOtlpExporter(); + + // HttpClient defaults — standard retry/circuit breaker for all named clients. + builder.Services.ConfigureHttpClientDefaults(http => http.AddStandardResilienceHandler()); + + builder.Services.AddHttpClient("HaveIBeenPwned", c => + { + c.BaseAddress = new Uri("https://api.pwnedpasswords.com/"); + c.DefaultRequestHeaders.UserAgent.ParseAdd("EssentialCSharp.Web/1.0"); + // Short timeout: this check is advisory/fail-open, so cap latency impact on auth flows. + c.Timeout = TimeSpan.FromSeconds(3); + }); + + builder.Services.AddTrustedForwardedHeaders(builder.Configuration, builder.Environment); + builder.Services.AddHttpsRedirection(options => + { + options.HttpsPort = 443; + }); + + ConfigurationManager configuration = builder.Configuration; + string connectionString = builder.Configuration.GetConnectionString("EssentialCSharpWebContextConnection") ?? throw new InvalidOperationException("Connection string 'EssentialCSharpWebContextConnection' not found."); + + builder.Services.AddDbContext(options => options.UseSqlServer(connectionString, sql => sql.EnableRetryOnFailure(5))); + + if (builder.Environment.IsDevelopment()) + { + // Must run before DataProtectionHostedService, which reads DataProtectionKeys. + builder.Services.AddHostedService(); + } + + // Data Protection — persist keys to SQL Server so they survive container restarts. + // SetApplicationName ensures the discriminator is stable across container hostname changes. + var dpBuilder = builder.Services.AddDataProtection() + .SetApplicationName("EssentialCSharpWeb") + .PersistKeysToDbContext(); + var keyVaultKeyUri = builder.Configuration["DataProtection:AzureKeyVaultKeyUri"]; + if (!string.IsNullOrEmpty(keyVaultKeyUri)) + { + dpBuilder.ProtectKeysWithAzureKeyVault(new Uri(keyVaultKeyUri), new Azure.Identity.DefaultAzureCredential()); + } + else if (!builder.Environment.IsDevelopment()) + { + throw new InvalidOperationException( + "DataProtection:AzureKeyVaultKeyUri is required in non-Development environments. " + + "Set the DataProtection__AzureKeyVaultKeyUri environment variable to the Key Vault key URI."); + } + + builder.Services.AddDefaultIdentity(options => + { + // Password settings + options.User.RequireUniqueEmail = true; + options.Password.RequiredLength = PasswordRequirementOptions.PasswordMinimumLength; + options.Password.RequireDigit = false; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequireUppercase = false; + options.Password.RequireLowercase = false; + options.Password.RequiredUniqueChars = 1; + + options.SignIn.RequireConfirmedEmail = true; + options.SignIn.RequireConfirmedAccount = true; + + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(30); + options.Lockout.MaxFailedAccessAttempts = 3; + + //TODO: Implement IProtectedUserStore + //options.Stores.ProtectPersonalData = true; + }) + .AddEntityFrameworkStores() + .AddPasswordValidator>() + .AddPasswordValidator>() + .AddPasswordValidator>(); + + builder.Configuration + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddUserSecrets() + .AddEnvironmentVariables(); + + builder.Services.ConfigureApplicationCookie(options => + { + options.Cookie.HttpOnly = true; + options.ExpireTimeSpan = TimeSpan.FromMinutes(60); + options.SlidingExpiration = true; + // API endpoints must return 401/403 instead of redirecting to the login page. + // Cookie auth's default behavior (302 redirect) causes fetch() to follow the + // redirect, eventually hitting the fallback controller and returning a 404. + options.Events.OnRedirectToLogin = context => + { + if (context.Request.Path.StartsWithSegments("/api") + || context.Request.Path.StartsWithSegments("/mcp")) + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + else + context.Response.Redirect(context.RedirectUri); + return Task.CompletedTask; + }; + options.Events.OnRedirectToAccessDenied = context => + { + if (context.Request.Path.StartsWithSegments("/api") + || context.Request.Path.StartsWithSegments("/mcp")) + context.Response.StatusCode = StatusCodes.Status403Forbidden; + else + context.Response.Redirect(context.RedirectUri); + return Task.CompletedTask; + }; + }); + + builder.Services.Configure(option => + { + // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 + // Minimum recommended is currently 210,000 iterations for pdkdf2-sha512 as of October 27, 2023 + option.IterationCount = 500000; + }); + builder.Services.AddScoped>(provider => + { + if (!provider.GetRequiredService>().SupportsUserEmail) + { + throw new NotSupportedException("The default UI requires a user store with email support."); + } + return (IUserEmailStore)provider.GetRequiredService>(); + }); + + builder.Services.AddScoped>(provider => + { + if (provider.GetRequiredService>() is IUserPasswordStore userPasswordStore) + { + return userPasswordStore; + } + throw new NotSupportedException("The default UI requires a user store with password support."); + }); + + builder.Services.AddControllersWithViews(options => + { + options.Filters.Add(new Microsoft.AspNetCore.Mvc.AutoValidateAntiforgeryTokenAttribute()); + }); + + if (!builder.Environment.IsDevelopment()) + { + builder.Services.AddTransient(); + } + builder.Services.Configure(builder.Configuration.GetSection(AuthMessageSenderOptions.AuthMessageSender)); + builder.Services.Configure(builder.Configuration.GetSection(SiteSettings.SectionName)); + + // Add services to the container. + builder.Services.AddRazorPages(); + builder.Services.AddOutputCache(); + builder.Services.AddCaptchaService(builder.Configuration.GetSection(CaptchaOptions.CaptchaSender)); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.Configure(builder.Configuration.GetSection(ReadingOptions.SectionName)); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // Add AI Chat services using configuration-driven backend selection. + builder.Services.AddConfiguredChatServices(configuration); + + // MCP server — always enabled, authenticated via opaque DB-backed tokens. + builder.Services.AddScoped(); + + builder.Services.AddMemoryCache(); + builder.Services.AddSingleton(_ => new ResponseIdValidationService()); + + builder.Services.AddAuthentication() + .AddScheme( + McpBearerAuthentication.Scheme, _ => { }); + + builder.Services.AddAuthorization(options => + options.AddPolicy("McpPolicy", policy => + policy.AddAuthenticationSchemes(McpBearerAuthentication.Scheme) + .RequireAuthenticatedUser())); + + builder.Services.AddCors(options => + options.AddPolicy("McpInspectorCors", policy => + policy.SetIsOriginAllowed(origin => + Uri.TryCreate(origin, UriKind.Absolute, out Uri? originUri) + && originUri.IsLoopback + && (originUri.Scheme == Uri.UriSchemeHttp || originUri.Scheme == Uri.UriSchemeHttps)) + .AllowAnyHeader() + .AllowAnyMethod() + .WithExposedHeaders("Mcp-Session-Id"))); + + builder.Services.AddSingleton(); + + builder.Services.AddMcpServer() + .WithHttpTransport(options => options.Stateless = true) + .WithTools() + .WithTools() + .WithTools() + .WithTools(); + + // Add Rate Limiting for API endpoints + builder.Services.AddRateLimiter(options => + { + // Global rate limiter for site requests by authenticated user ID or anonymous IP. + // MCP transport requests use a dedicated named policy attached to MapMcp("/mcp"). + options.GlobalLimiter = PartitionedRateLimiter.Create(httpContext => + { + if (httpContext.Request.Path.StartsWithSegments("/.well-known")) + return RateLimitPartition.GetNoLimiter("well-known"); + + if (IsMcpTransportRequest(httpContext.Request)) + return RateLimitPartition.GetNoLimiter("mcp-transport"); + + // Reading API endpoints are limited by their own named policies. + if (httpContext.Request.Path.StartsWithSegments("/api/reading")) + return RateLimitPartition.GetNoLimiter("reading-api"); + + var partitionKey = httpContext.User.Identity?.IsAuthenticated == true + ? httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown-user" + : httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"; + + return RateLimitPartition.GetFixedWindowLimiter( + partitionKey: partitionKey, + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 30, // requests per window + Window = TimeSpan.FromMinutes(1), // minute window + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 0 // No queuing - immediate rejection for better UX + }); + }); + + options.AddPolicy("ChatEndpoint", httpContext => + { + // Partitioned per-user (when authenticated) or per-IP (anonymous) + var partitionKey = httpContext.User.Identity?.IsAuthenticated == true + ? $"chat-user:{httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown-user"}" + : $"chat-ip:{httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"}"; + + return RateLimitPartition.GetFixedWindowLimiter( + partitionKey: partitionKey, + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 15, + Window = TimeSpan.FromMinutes(1), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 0 + }); + }); + + // Reading tracker: per-user limit for authenticated endpoints, looser per-IP limit for anonymous book-stats. + options.AddPolicy(ReadingController.WriteRateLimitPolicy, httpContext => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: $"reading-user:{httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"}", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 20, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0 + })); + options.AddPolicy(ReadingController.PublicRateLimitPolicy, httpContext => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: $"reading-ip:{httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"}", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 60, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0 + })); + + // Combined per-minute burst (10/min) + per-hour cap (150/hr) for book content pages. + // A scraper cycling through the full ~400-page book needs 2+ hours at minimum. + // See Services/ContentRateLimiterPolicy.cs for implementation. + options.AddPolicy("content", new ContentRateLimiterPolicy()); + options.AddPolicy(McpRateLimiterPolicy.PolicyName, new McpRateLimiterPolicy()); + + // Custom response when rate limit is exceeded + options.OnRejected = async (context, cancellationToken) => + { + context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + int? retryAfterSeconds = RateLimitingResponseHelpers.ApplyRetryAfterHeader( + context.HttpContext.Response, + context.Lease); + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + + if (context.HttpContext.Request.Path.StartsWithSegments("/api/chat")) + { + // Custom rejection handling logic + context.HttpContext.Response.ContentType = "application/json"; + + Dictionary errorResponse = new() + { + ["error"] = "Rate limit exceeded. Please wait before sending another message.", + ["statusCode"] = 429 + }; + if (retryAfterSeconds is int retryAfter) + errorResponse["retryAfter"] = retryAfter; + + await context.HttpContext.Response.WriteAsync( + System.Text.Json.JsonSerializer.Serialize(errorResponse), + cancellationToken); + + // Optional logging + LogRateLimitExceeded( + logger, + context.HttpContext.Request.Path, + context.HttpContext.User.Identity?.Name ?? "anonymous", + context.HttpContext.Connection.RemoteIpAddress); + return; + } + + await context.HttpContext.Response.WriteAsync("Rate limit exceeded. Please try again later.", cancellationToken); + + LogRateLimitExceeded( + logger, + context.HttpContext.Request.Path, + context.HttpContext.User.Identity?.Name ?? "anonymous", + context.HttpContext.Connection.RemoteIpAddress); + }; + }); + + if (!builder.Environment.IsDevelopment()) + { + builder.Services.AddHttpClient(client => + { + //set BaseAddress, MediaType, UserAgent + client.SetDefaultSettings(); + + client.UseBasicAuthentication(configuration["AuthMessageSender:APIKey"], configuration["AuthMessageSender:SecretKey"]); + }); + } + + if (!builder.Environment.IsDevelopment()) + { + builder.Services.AddAuthentication() + .AddMicrosoftAccount(microsoftoptions => + { + microsoftoptions.ClientId = configuration["authentication:microsoft:clientid"] ?? throw new InvalidOperationException("authentication:microsoft:clientid unexpectedly null"); + microsoftoptions.ClientSecret = configuration["authentication:microsoft:clientsecret"] ?? throw new InvalidOperationException("authentication:microsoft:clientsecret unexpectedly null"); + }) + .AddGitHub(o => + { + o.ClientId = configuration["authentication:github:clientId"] ?? throw new InvalidOperationException("github:clientId unexpectedly null"); + o.ClientSecret = configuration["authentication:github:clientSecret"] ?? throw new InvalidOperationException("github:clientSecret unexpectedly null"); + + // Grants access to read a user's profile data. + // https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps + o.Scope.Add("read:user"); + }); + } + + WebApplication app = builder.Build(); + + // Build the page word-count cache during startup instead of on the first content request. + _ = app.Services.GetRequiredService(); + + if (profilerSkippedUnsupportedPlatform) + LogSkippingUnsupportedAzureMonitorProfiler( + app.Services.GetRequiredService>(), + RuntimeInformation.OSDescription); + + // Configure the HTTP request pipeline. + if (!app.Environment.IsDevelopment()) + { + SiteSettings siteSettings = app.Services.GetRequiredService>().Value; + if (!Uri.TryCreate(siteSettings.BaseUrl, UriKind.Absolute, out Uri? configuredBaseUri)) + { + throw new InvalidOperationException($"Invalid {SiteSettings.SectionName}:{nameof(SiteSettings.BaseUrl)} value: '{siteSettings.BaseUrl}'."); + } + string apexHost = configuredBaseUri.Host.StartsWith("www.", StringComparison.OrdinalIgnoreCase) + ? configuredBaseUri.Host[4..] + : configuredBaseUri.Host; + string wwwHost = $"www.{apexHost}"; + Uri apexUri = new UriBuilder(configuredBaseUri) { Host = apexHost }.Uri; + string redirectScheme = apexUri.Scheme; + HostString redirectHost = new HostString(apexUri.Authority); + + app.UseExceptionHandler(exceptionApp => + { + exceptionApp.Run(async context => + { + var exceptionFeature = context.Features.Get(); + var logger = context.RequestServices.GetRequiredService>(); + LogUnhandledException(logger, exceptionFeature?.Error, context.Request.Path); + + if (context.Request.Path.StartsWithSegments("/mcp")) + { + await McpJsonRpcResponseWriter.WriteErrorAsync( + context.Response, + StatusCodes.Status500InternalServerError, + -32603, + "An unexpected error occurred while processing the MCP request.", + context.RequestAborted); + } + else if (context.Request.Path.StartsWithSegments("/api")) + { + context.Response.StatusCode = 500; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync(new { error = "An unexpected error occurred" }); + } + else + { + context.Response.Redirect("/Home/Error?statusCode=500"); + } + }); + }); + // Skip manual UseForwardedHeaders when ASPNETCORE_FORWARDEDHEADERS_ENABLED=true; + // the built-in startup filter already called it before this pipeline runs. + if (!string.Equals(app.Configuration["ASPNETCORE_FORWARDEDHEADERS_ENABLED"], "true", StringComparison.OrdinalIgnoreCase)) + { + app.UseForwardedHeaders(); + } + + // Build dynamic CSP — TryDotNet origin comes from runtime config + string? tryDotNetOrigin = app.Configuration["TryDotNet:Origin"]; + string tryDotNetSources = string.Empty; + if (!string.IsNullOrWhiteSpace(tryDotNetOrigin)) + { + if (Uri.TryCreate(tryDotNetOrigin, UriKind.Absolute, out Uri? tryDotNetUri)) + { + tryDotNetSources = $" {tryDotNetUri.GetLeftPart(UriPartial.Authority)}"; + } + else + { + LogIgnoringInvalidTryDotNetOrigin(app.Logger, tryDotNetOrigin); + } + } + + string csp = string.Join("; ", + $"default-src 'self'", + $"script-src 'self' 'unsafe-inline' cdn.jsdelivr.net www.clarity.ms www.googletagmanager.com js.monitor.azure.com https://hcaptcha.com https://*.hcaptcha.com{tryDotNetSources}", + $"style-src 'self' 'nonce-{CspNonce.Placeholder}' cdnjs.cloudflare.com fonts.googleapis.com https://hcaptcha.com https://*.hcaptcha.com", + $"style-src-attr 'unsafe-inline'", + $"font-src 'self' fonts.gstatic.com cdnjs.cloudflare.com", + $"img-src 'self' data: https:", + $"connect-src 'self' https://hcaptcha.com https://*.hcaptcha.com https://api.pwnedpasswords.com https://*.algolia.net https://*.algolianet.com https://*.google-analytics.com https://*.clarity.ms https://*.in.applicationinsights.azure.com{GetApplicationInsightsCspSources(app.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"], app.Logger)}{tryDotNetSources}", + $"frame-src https://hcaptcha.com https://*.hcaptcha.com https://newassets.hcaptcha.com{tryDotNetSources}", + $"worker-src blob:", + $"frame-ancestors 'none'", + $"base-uri 'self'", + $"form-action 'self' https://login.microsoftonline.com https://github.com" + ); + + app.UseSecurityHeadersMiddleware(new SecurityHeadersBuilder() + .AddDefaultSecurePolicy() + .AddContentSecurityPolicy(csp)); + + // Redirect configured www host to configured apex host (permanent 301). + // Must be after UseForwardedHeaders so the Host header reflects the real hostname. + app.Use(async (context, next) => + { + if (string.Equals(context.Request.Host.Host, wwwHost, StringComparison.OrdinalIgnoreCase)) + { + string redirectUrl = UriHelper.BuildAbsolute(redirectScheme, redirectHost, context.Request.PathBase, context.Request.Path, context.Request.QueryString); + context.Response.Redirect(redirectUrl, permanent: true); + return; + } + await next(context); + }); + } + else + { + app.UseDeveloperExceptionPage(); + if (!string.Equals(app.Configuration["ASPNETCORE_FORWARDEDHEADERS_ENABLED"], "true", StringComparison.OrdinalIgnoreCase)) + { + app.UseForwardedHeaders(); + } + } + + app.MapHealthChecks("/health").DisableRateLimiting(); + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }).DisableRateLimiting(); + + if (!app.Environment.IsDevelopment()) + { + app.UseHttpsRedirection(); + } + app.UseStaticFiles(); + + app.UseRouting(); + + app.UseWhen( + context => context.Request.Path.StartsWithSegments("/mcp"), + branch => branch.UseCors("McpInspectorCors")); + + app.UseAuthentication(); + + // /mcp uses a named non-default scheme. Normalize the principal before + // rate limiting so valid MCP requests partition by MCP user while + // missing/invalid bearer requests fall back to the anonymous/IP bucket + // instead of inheriting the site's cookie principal. + app.UseWhen( + context => context.Request.Path.StartsWithSegments("/mcp"), + branch => branch.UseMiddleware()); + + app.UseRateLimiter(); + + app.UseAuthorization(); + app.UseOutputCache(); + + app.UseMiddleware(); + + app.MapRazorPages(); + app.MapDefaultControllerRoute(); + + app.MapMethods("/mcp", [HttpMethods.Get], (HttpResponse response) => + { + response.Headers.Append("Allow", HttpMethods.Post); + response.Headers.CacheControl = "no-store"; + return Results.StatusCode(StatusCodes.Status405MethodNotAllowed); + }); + + app.MapMcp("/mcp") + .RequireAuthorization("McpPolicy") + .RequireRateLimiting(McpRateLimiterPolicy.PolicyName); + + app.Map("/.well-known", (HttpResponse response) => + { + response.Headers.CacheControl = "no-store"; + return Results.NotFound(); + }).DisableRateLimiting(); + + app.Map("/.well-known/{**path}", (HttpResponse response) => + { + response.Headers.CacheControl = "no-store"; + return Results.NotFound(); + }).DisableRateLimiting(); + + app.MapFallbackToController("Index", "Home"); + + // Validate sitemap data at startup — logs errors but allows startup to continue + var siteMappingService = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService>(); + + try + { + SitemapXmlHelpers.EnsureSitemapHealthy(siteMappingService.SiteMappings.ToList()); + LogSitemapValidationSucceeded(logger); + } + catch (InvalidOperationException ex) + { + LogSitemapValidationFailed(logger, ex); + // Continue startup even if sitemap validation fails + } + + app.Run(); + } + + private static bool IsMcpTransportRequest(HttpRequest request) => + HttpMethods.IsPost(request.Method) && request.Path == "/mcp"; + + [LoggerMessage(Level = LogLevel.Warning, Message = "Rate limit exceeded on {Path}. User: {User}, IP: {IpAddress}")] + private static partial void LogRateLimitExceeded(ILogger logger, PathString path, string user, System.Net.IPAddress? ipAddress); + + [LoggerMessage(Level = LogLevel.Error, Message = "Unhandled exception on {Path}")] + private static partial void LogUnhandledException(ILogger logger, Exception? exception, PathString path); + + [LoggerMessage(Level = LogLevel.Information, Message = "Sitemap validation completed successfully during application startup")] + private static partial void LogSitemapValidationSucceeded(ILogger logger); + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to validate sitemap during application startup")] + private static partial void LogSitemapValidationFailed(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Ignoring invalid TryDotNet origin in CSP: {Origin}")] + private static partial void LogIgnoringInvalidTryDotNetOrigin(ILogger logger, string origin); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Azure Monitor profiler is not supported on this platform ({Platform}). Skipping profiler registration and continuing with Azure Monitor telemetry export.")] + private static partial void LogSkippingUnsupportedAzureMonitorProfiler(ILogger logger, string platform); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Application Insights connection string has a non-HTTPS or unparseable IngestionEndpoint value ({Endpoint}); omitting from CSP connect-src.")] + private static partial void LogInvalidApplicationInsightsIngestionEndpoint(ILogger logger, string? endpoint); + + private static string GetApplicationInsightsCspSources(string? connectionString, ILogger? logger = null) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + return string.Empty; + } + + string? ingestionEndpoint = GetConnectionStringValue(connectionString, "IngestionEndpoint"); + if (string.IsNullOrWhiteSpace(ingestionEndpoint) + || !Uri.TryCreate(ingestionEndpoint, UriKind.Absolute, out Uri? ingestionUri) + || ingestionUri.Scheme != Uri.UriSchemeHttps) + { + if (logger is not null) + { + LogInvalidApplicationInsightsIngestionEndpoint(logger, ingestionEndpoint); + } + return string.Empty; + } + + return $" {ingestionUri.GetLeftPart(UriPartial.Authority)}"; + } + + private static string? GetConnectionStringValue(string connectionString, string key) + { + foreach (string segment in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + int separatorIndex = segment.IndexOf('='); + if (separatorIndex <= 0) + { + continue; + } + + string currentKey = segment[..separatorIndex]; + if (!currentKey.Equals(key, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + return segment[(separatorIndex + 1)..].Trim('"'); + } + + return null; + } +} diff --git a/EssentialCSharp.Web/Services/IReadingProfileService.cs b/EssentialCSharp.Web/Services/IReadingProfileService.cs new file mode 100644 index 00000000..1d3bc12b --- /dev/null +++ b/EssentialCSharp.Web/Services/IReadingProfileService.cs @@ -0,0 +1,23 @@ +namespace EssentialCSharp.Web.Services; + +/// A single reading interval reported by a client. +public sealed record ReadingInterval(string PageKey, int WordsRead, int ActiveSeconds); + +/// +/// A user's aggregate reading totals. Serialized as-is by the reading API +/// (totalWordsRead, totalActiveSeconds, wpm). +/// +public sealed record ReadingProfileSummary(long TotalWordsRead, long TotalActiveSeconds, double? Wpm); + +/// Outcome of recording a session: either the updated profile or the first unknown page key. +public sealed record RecordSessionResult(ReadingProfileSummary? Profile, string? UnknownPageKey); + +public interface IReadingProfileService +{ + Task RecordSessionAsync( + string userId, IReadOnlyList intervals, CancellationToken cancellationToken); + + Task GetProfileAsync(string userId, CancellationToken cancellationToken); + + Task ResetAsync(string userId, CancellationToken cancellationToken); +} diff --git a/EssentialCSharp.Web/Services/ReadingOptions.cs b/EssentialCSharp.Web/Services/ReadingOptions.cs new file mode 100644 index 00000000..6753385a --- /dev/null +++ b/EssentialCSharp.Web/Services/ReadingOptions.cs @@ -0,0 +1,21 @@ +namespace EssentialCSharp.Web.Services; + +/// +/// Tunables for the reading-time tracker (mirrors Kindle's ReadingTimer values). +/// +public sealed class ReadingOptions +{ + public const string SectionName = "Reading"; + + /// Intervals faster than this many words per minute are implausible and are ignored. + public int HardCutoffWpm { get; set; } = 900; + + /// + /// Intervals slower than this fraction of the reader's reference WPM are credited + /// as if they had been read at exactly that fraction of the reference speed. + /// + public double SlowOutlierFactor { get; set; } = 0.25; + + /// Maximum number of intervals accepted in a single request. + public int MaxIntervalsPerRequest { get; set; } = 50; +} diff --git a/EssentialCSharp.Web/Services/ReadingProfileService.cs b/EssentialCSharp.Web/Services/ReadingProfileService.cs new file mode 100644 index 00000000..3efee7bb --- /dev/null +++ b/EssentialCSharp.Web/Services/ReadingProfileService.cs @@ -0,0 +1,119 @@ +using EssentialCSharp.Web.Data; +using EssentialCSharp.Web.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace EssentialCSharp.Web.Services; + +public sealed partial class ReadingProfileService( + EssentialCSharpWebContext context, + IWordCountService wordCountService, + IOptions options, + ILogger logger) : IReadingProfileService +{ + private readonly ReadingOptions _options = options.Value; + + [LoggerMessage(Level = LogLevel.Debug, Message = "Ignored interval for {PageKey}: {Words} words in {Seconds}s is not credited")] + private static partial void LogIntervalIgnored(ILogger logger, string pageKey, int words, int seconds); + + public async Task RecordSessionAsync( + string userId, IReadOnlyList intervals, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(intervals); + + foreach (ReadingInterval interval in intervals) + { + if (wordCountService.GetPageWordCount(interval.PageKey) <= 0) + { + return new RecordSessionResult(null, interval.PageKey); + } + } + + DateTime now = DateTime.UtcNow; + + // One consistent reference speed for the whole batch. + ReadingProfileSummary current = await GetProfileAsync(userId, cancellationToken); + double referenceWpm = current.Wpm ?? 0; + + long deltaWords = 0; + long deltaSeconds = 0; + foreach (ReadingInterval interval in intervals) + { + int words = Math.Min(interval.WordsRead, wordCountService.GetPageWordCount(interval.PageKey)); + ReadingCredit? credit = ReadingSpeedPolicy.Evaluate(words, interval.ActiveSeconds, referenceWpm, _options); + if (credit is null) + { + LogIntervalIgnored(logger, interval.PageKey, words, interval.ActiveSeconds); + continue; + } + + deltaWords += credit.Value.Words; + deltaSeconds += credit.Value.Seconds; + } + + if (deltaWords > 0 || deltaSeconds > 0) + { + await AddToProfileAsync(userId, deltaWords, deltaSeconds, now, cancellationToken); + } + + return new RecordSessionResult(await GetProfileAsync(userId, cancellationToken), null); + } + + public async Task GetProfileAsync(string userId, CancellationToken cancellationToken) + { + var totals = await context.UserReadingProfiles + .AsNoTracking() + .Where(p => p.UserId == userId) + .Select(p => new { p.TotalWordsRead, p.TotalActiveSeconds }) + .FirstOrDefaultAsync(cancellationToken); + + return totals is null + ? new ReadingProfileSummary(0, 0, null) + : new ReadingProfileSummary( + totals.TotalWordsRead, + totals.TotalActiveSeconds, + UserReadingProfile.DeriveWpm(totals.TotalWordsRead, totals.TotalActiveSeconds)); + } + + public async Task ResetAsync(string userId, CancellationToken cancellationToken) => + await context.UserReadingProfiles + .Where(p => p.UserId == userId) + .ExecuteDeleteAsync(cancellationToken); + + /// + /// Atomically creates the profile row or adds the deltas to it in a single statement, + /// so concurrent requests for the same user never lose an increment or violate the key. + /// + private async Task AddToProfileAsync( + string userId, long words, long seconds, DateTime now, CancellationToken cancellationToken) + { + if (context.Database.IsSqlServer()) + { + await context.Database.ExecuteSqlAsync( + $""" + MERGE UserReadingProfiles WITH (HOLDLOCK) AS target + USING (SELECT {userId} AS UserId) AS source ON target.UserId = source.UserId + WHEN MATCHED THEN UPDATE SET + TotalWordsRead = target.TotalWordsRead + {words}, + TotalActiveSeconds = target.TotalActiveSeconds + {seconds}, + UpdatedAtUtc = {now} + WHEN NOT MATCHED THEN INSERT (UserId, TotalWordsRead, TotalActiveSeconds, UpdatedAtUtc) + VALUES ({userId}, {words}, {seconds}, {now}); + """, + cancellationToken); + } + else // SQLite (used by the integration tests) + { + await context.Database.ExecuteSqlAsync( + $""" + INSERT INTO UserReadingProfiles (UserId, TotalWordsRead, TotalActiveSeconds, UpdatedAtUtc) + VALUES ({userId}, {words}, {seconds}, {now}) + ON CONFLICT (UserId) DO UPDATE SET + TotalWordsRead = TotalWordsRead + excluded.TotalWordsRead, + TotalActiveSeconds = TotalActiveSeconds + excluded.TotalActiveSeconds, + UpdatedAtUtc = excluded.UpdatedAtUtc + """, + cancellationToken); + } + } +} diff --git a/EssentialCSharp.Web/Services/ReadingSpeedPolicy.cs b/EssentialCSharp.Web/Services/ReadingSpeedPolicy.cs new file mode 100644 index 00000000..03b1c5e9 --- /dev/null +++ b/EssentialCSharp.Web/Services/ReadingSpeedPolicy.cs @@ -0,0 +1,43 @@ +namespace EssentialCSharp.Web.Services; + +/// Words and seconds credited to a reader for one accepted interval. +public readonly record struct ReadingCredit(int Words, int Seconds); + +/// +/// Pure outlier rules applied to each reported reading interval. +/// +public static class ReadingSpeedPolicy +{ + /// + /// Decides how much of an interval to credit. + /// Returns when the interval is not usable (non-positive input or faster + /// than ). Intervals slower than + /// times are credited + /// with fewer seconds than their wall-clock time so idle stretches do not drag the average down. + /// + /// The reader's current WPM, or 0 when unknown. + public static ReadingCredit? Evaluate(int words, int seconds, double referenceWpm, ReadingOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (words <= 0 || seconds <= 0) + { + return null; + } + + double intervalWpm = words / (seconds / 60.0); + if (intervalWpm > options.HardCutoffWpm) + { + return null; + } + + double slowThreshold = options.SlowOutlierFactor * referenceWpm; + if (referenceWpm > 0 && intervalWpm < slowThreshold) + { + int clampedSeconds = (int)Math.Max(1, Math.Ceiling(words / slowThreshold * 60.0)); + return new ReadingCredit(words, Math.Min(seconds, clampedSeconds)); + } + + return new ReadingCredit(words, seconds); + } +} From 14a0bed310ea6e576d43484ceefd81d685764b9d Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Thu, 5 Nov 2026 21:07:27 -0800 Subject: [PATCH 10/38] refactor(reading): build word-count snapshot at host start with per-page stats Single immutable PageWordStats snapshot (FrozenDictionary), built in IHostedLifecycleService.StartingAsync; logs and isolates bad content files, decodes HTML entities, counts inline code but not code blocks. Tests rewritten around requirements. --- .../WordCountServiceTests.cs | 507 ++++++++++-------- EssentialCSharp.Web/Program.cs | 8 +- .../Services/IWordCountService.cs | 47 +- .../Services/WordCountService.cs | 236 ++++---- 4 files changed, 441 insertions(+), 357 deletions(-) diff --git a/EssentialCSharp.Web.Tests/WordCountServiceTests.cs b/EssentialCSharp.Web.Tests/WordCountServiceTests.cs index b726407f..8d82a93a 100644 --- a/EssentialCSharp.Web.Tests/WordCountServiceTests.cs +++ b/EssentialCSharp.Web.Tests/WordCountServiceTests.cs @@ -1,311 +1,374 @@ using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Moq; namespace EssentialCSharp.Web.Tests; public class WordCountServiceTests { - // ---- Helpers ---- + // Three prose words used as a constant baseline next to whatever is being included or excluded. + private const string Baseline = "

alpha beta gamma

"; + private const int BaselineWords = 3; - private static (string tempDir, string filePath) WriteTempHtmlFile(string html) + [Test] + public async Task PlainProse_IsCountedByWhitespaceSeparatedWords() { - string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(tempDir); - string filePath = Path.Combine(tempDir, "page.html"); - File.WriteAllText(filePath, html); - return (tempDir, filePath); + using TempContentDirectory dir = new(); + dir.Write("page.html", "

Hello world, this\nis a test.

"); + + WordCountService service = await dir.BuildAsync(Map("page", "page.html", 1, 1)); + + await Assert.That(service.GetPageWordCount("page")).IsEqualTo(6); } - private static SiteMapping MakeMapping( - string key, - string contentRoot, - string relativePath, - int chapter, - int page, - int order = 1, - string[]? keys = null) + [Test] + [Arguments("
public static void Main() { var x = 1; }
")] + [Arguments("
Console.WriteLine(x);
")] + [Arguments("")] + [Arguments("")] + [Arguments("
Listing 1.16: Using it
public class HeyYou
")] + [Arguments("
deeply nested code here
")] + [Arguments("
pre inside a generated listing
")] + public async Task CodeListingsAndNonProseTags_DoNotAddToTheCount(string excludedHtml) { - // PagePath is relative to contentRoot as individual path segments. - string[] segments = relativePath.Split('/', '\\').Where(s => s.Length > 0).ToArray(); - return new SiteMapping( - keys: keys?.ToList() ?? [key], - primaryKey: key, - pagePath: segments, - chapterNumber: chapter, - pageNumber: page, - orderOnPage: order, - chapterTitle: $"Chapter {chapter}", - rawHeading: key, - anchorId: key, - indentLevel: 0 - ); + using TempContentDirectory dir = new(); + dir.Write("page.html", $"\n{Baseline}\n{excludedHtml}\n{Baseline}\n"); + + WordCountService service = await dir.BuildAsync(Map("page", "page.html", 1, 1)); + + await Assert.That(service.GetPageWordCount("page")).IsEqualTo(2 * BaselineWords); } - private static (WordCountService service, string tempDir) CreateService(IList mappings, string contentRoot) + [Test] + public async Task InlineCodeInProse_IsCounted() { - Mock siteMappingMock = new(); - siteMappingMock.Setup(s => s.SiteMappings).Returns(mappings); + using TempContentDirectory dir = new(); + dir.Write("page.html", "

Call Console.WriteLine now

"); - Mock envMock = new(); - envMock.Setup(e => e.ContentRootPath).Returns(contentRoot); - // IWebHostEnvironment also inherits IHostEnvironment, but WordCountService only uses ContentRootPath. + WordCountService service = await dir.BuildAsync(Map("page", "page.html", 1, 1)); - var service = new WordCountService(siteMappingMock.Object, envMock.Object); - return (service, contentRoot); + await Assert.That(service.GetPageWordCount("page")).IsEqualTo(3); } - // ---- Tests ---- + [Test] + public async Task HtmlEntities_AreDecodedBeforeCounting() + { + using TempContentDirectory dir = new(); + // Decoded text is "a b & c List": a non-breaking space separates words, and entities are not words themselves. + dir.Write("page.html", "

a b & c List<T>

"); + + WordCountService service = await dir.BuildAsync(Map("page", "page.html", 1, 1)); + + await Assert.That(service.GetPageWordCount("page")).IsEqualTo(5); + } [Test] - public async Task GetPageWordCount_PlainProse_ReturnsCorrectCount() + [Arguments("", 0)] + [Arguments("", 0)] + [Arguments(" \n ", 0)] + [Arguments("

one two

", 2)] // fragment without / + public async Task EmptyOrBodylessContent_IsCountedWithoutFailing(string html, int expected) { - // Arrange - const string html = "

Hello world this is a test.

"; - (string tempDir, string filePath) = WriteTempHtmlFile(html); - try - { - string relativePath = Path.GetFileName(filePath); - SiteMapping mapping = MakeMapping("page1", tempDir, relativePath, 1, 1); - (WordCountService service, _) = CreateService([mapping], tempDir); + using TempContentDirectory dir = new(); + dir.Write("page.html", html); - // Act - int count = service.GetPageWordCount("page1"); + WordCountService service = await dir.BuildAsync(Map("page", "page.html", 1, 1)); - // Assert — "Hello world this is a test." = 6 words - await Assert.That(count).IsEqualTo(6); - } - finally - { - Directory.Delete(tempDir, recursive: true); - } + await Assert.That(service.GetPageWordCount("page")).IsEqualTo(expected); } [Test] - public async Task GetPageWordCount_ExcludesPreAndCodeBlocks() + public async Task UnknownKeyHasNoStats_ButMappedKeyWithMissingFileCountsZero() { - // Arrange — 3 prose words + code block that should be excluded - const string html = """ - -

Hello world prose.

-
public static void Main() { var x = 1; }
- Console.WriteLine(x); - - """; - (string tempDir, string filePath) = WriteTempHtmlFile(html); - try - { - string relativePath = Path.GetFileName(filePath); - SiteMapping mapping = MakeMapping("page2", tempDir, relativePath, 1, 1); - (WordCountService service, _) = CreateService([mapping], tempDir); + using TempContentDirectory dir = new(); + dir.Write("present.html", $"{Baseline}"); + CapturingLogger logger = new(); - // Act - int count = service.GetPageWordCount("page2"); + WordCountService service = await dir.BuildAsync( + logger, + Map("present", "present.html", 1, 1), + Map("missing", "nonexistent.html", 1, 2)); - // Assert — only "Hello world prose." = 3 words - await Assert.That(count).IsEqualTo(3); - } - finally + using (Assert.Multiple()) { - Directory.Delete(tempDir, recursive: true); + await Assert.That(service.GetPageStats("not-a-key")).IsNull(); + await Assert.That(service.GetPageWordCount("not-a-key")).IsEqualTo(0); + await Assert.That(service.GetPageStats("missing")).IsNotNull(); + await Assert.That(service.GetPageWordCount("missing")).IsEqualTo(0); + await Assert.That(service.GetPageWordCount("present")).IsEqualTo(BaselineWords); + await Assert.That(logger.Warnings).HasSingleItem(); } } [Test] - public async Task GetPageWordCount_ExcludesGeneratedCodeBlockSection() + public async Task UnreadableFile_IsLoggedAndDoesNotAffectOtherPages() { - const string html = """ - -

Prose before the listing.

-
-
Listing 1.16: Using Console.ReadLine()
-
-
- 1. - public class HeyYou -
-
- 2. - { Console.WriteLine("Hello world"); } -
-
-
-

Prose after the listing.

- - """; - (string tempDir, string filePath) = WriteTempHtmlFile(html); - try + using TempContentDirectory dir = new(); + dir.Write("a.html", $"{Baseline}"); + dir.Write("locked.html", $"{Baseline}"); + dir.Write("c.html", $"{Baseline}"); + CapturingLogger logger = new(); + + // An exclusive handle makes the read fail with an IOException. + using FileStream exclusive = new(dir.PathOf("locked.html"), FileMode.Open, FileAccess.Read, FileShare.None); + WordCountService service = await dir.BuildAsync( + logger, + Map("a", "a.html", 1, 1), + Map("locked", "locked.html", 1, 2), + Map("c", "c.html", 1, 3)); + + using (Assert.Multiple()) { - SiteMapping mapping = MakeMapping("generated-code", tempDir, Path.GetFileName(filePath), 1, 1); - (WordCountService service, _) = CreateService([mapping], tempDir); - - await Assert.That(service.GetPageWordCount("generated-code")).IsEqualTo(8); - } - finally - { - Directory.Delete(tempDir, recursive: true); + await Assert.That(service.IsReady).IsTrue(); + await Assert.That(service.GetPageWordCount("a")).IsEqualTo(BaselineWords); + await Assert.That(service.GetPageWordCount("locked")).IsEqualTo(0); + await Assert.That(service.GetPageWordCount("c")).IsEqualTo(BaselineWords); + await Assert.That(service.GetPageStats("c")?.WordsBeforePage).IsEqualTo(BaselineWords); + await Assert.That(logger.Warnings).HasSingleItem(); } } [Test] - public async Task GetPageWordCount_ExcludesScriptAndStyle() + public async Task PagePathEscapingContentRoot_IsSkipped() { - const string html = """ - - -

Two words.

- - - """; - (string tempDir, string filePath) = WriteTempHtmlFile(html); - try - { - string relativePath = Path.GetFileName(filePath); - SiteMapping mapping = MakeMapping("page3", tempDir, relativePath, 1, 1); - (WordCountService service, _) = CreateService([mapping], tempDir); + using TempContentDirectory dir = new(); + dir.Write("ok.html", $"{Baseline}"); + CapturingLogger logger = new(); - int count = service.GetPageWordCount("page3"); + WordCountService service = await dir.BuildAsync( + logger, + Map("ok", "ok.html", 1, 1), + Map("escape", "../outside.html", 1, 2)); - // "Two words." = 2 - await Assert.That(count).IsEqualTo(2); - } - finally + using (Assert.Multiple()) { - Directory.Delete(tempDir, recursive: true); + await Assert.That(service.GetPageStats("escape")).IsNull(); + await Assert.That(service.GetPageWordCount("ok")).IsEqualTo(BaselineWords); + await Assert.That(logger.Warnings).HasSingleItem(); } } [Test] - public async Task GetPageWordCount_FileNotFound_ReturnsZero() + public async Task PageStats_ReportPageChapterAndBookOffsets() { - // Arrange — map a page that doesn't have a corresponding file - string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(tempDir); - try - { - SiteMapping mapping = MakeMapping("missing", tempDir, "nonexistent.html", 1, 1); - (WordCountService service, _) = CreateService([mapping], tempDir); + using TempContentDirectory dir = new(); + dir.Write("a.html", "

one two

"); // 2 + dir.Write("b.html", "

three

"); // 1 + dir.Write("c.html", "

four five six

"); // 3 (chapter 2) - int count = service.GetPageWordCount("missing"); + WordCountService service = await dir.BuildAsync( + Map("a", "a.html", 1, 1), + Map("b", "b.html", 1, 2), + Map("c", "c.html", 2, 1)); - await Assert.That(count).IsEqualTo(0); - } - finally + using (Assert.Multiple()) { - Directory.Delete(tempDir, recursive: true); + await Assert.That(service.GetPageStats("a")).IsEqualTo(new PageWordStats(2, 3, 0, 0, 6)); + await Assert.That(service.GetPageStats("b")).IsEqualTo(new PageWordStats(1, 3, 2, 2, 6)); + await Assert.That(service.GetPageStats("c")).IsEqualTo(new PageWordStats(3, 3, 3, 0, 6)); + await Assert.That(service.GetBookWordCount()).IsEqualTo(6); } } [Test] - public async Task GetChapterWordCount_SumsAllPagesInChapter() + public async Task MappingsInAnyInputOrder_YieldReadingOrderOffsets() { - string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(tempDir); - try + using TempContentDirectory dir = new(); + dir.Write("a.html", "

one two

"); + dir.Write("b.html", "

three

"); + dir.Write("c.html", "

four five six

"); + + WordCountService service = await dir.BuildAsync( + Map("c", "c.html", 2, 1), + Map("b", "b.html", 1, 2), + Map("a", "a.html", 1, 1)); + + using (Assert.Multiple()) { - // Page A: 3 words; Page B: 4 words → chapter total 7 - File.WriteAllText(Path.Combine(tempDir, "a.html"), "

one two three

"); - File.WriteAllText(Path.Combine(tempDir, "b.html"), "

four five six seven

"); - - SiteMapping[] mappings = - [ - MakeMapping("a", tempDir, "a.html", 1, 1), - MakeMapping("b", tempDir, "b.html", 1, 2), - ]; - (WordCountService service, _) = CreateService(mappings, tempDir); - - await Assert.That(service.GetChapterWordCount(1)).IsEqualTo(7); + await Assert.That(service.GetPageStats("a")?.WordsBeforePage).IsEqualTo(0); + await Assert.That(service.GetPageStats("b")?.WordsBeforePage).IsEqualTo(2); + await Assert.That(service.GetPageStats("b")?.ChapterStartWords).IsEqualTo(2); + await Assert.That(service.GetPageStats("c")?.WordsBeforePage).IsEqualTo(3); + await Assert.That(service.GetPageStats("c")?.ChapterStartWords).IsEqualTo(0); } - finally + } + + [Test] + public async Task ChapterWordCounts_AreOrderedByChapterAndSumTheirPages() + { + using TempContentDirectory dir = new(); + dir.Write("a.html", "

one two three

"); // ch 1: 3 + dir.Write("b.html", "

four five six seven

"); // ch 1: 4 + dir.Write("c.html", "

eight

"); // ch 3: 1 + + WordCountService service = await dir.BuildAsync( + Map("c", "c.html", 3, 1), + Map("a", "a.html", 1, 1), + Map("b", "b.html", 1, 2)); + + IReadOnlyList chapters = service.GetChapterWordCounts(); + using (Assert.Multiple()) { - Directory.Delete(tempDir, recursive: true); + await Assert.That(string.Join(",", chapters.Select(c => c.ChapterNumber))).IsEqualTo("1,3"); + await Assert.That(string.Join(",", chapters.Select(c => c.WordCount))).IsEqualTo("7,1"); + await Assert.That(service.GetBookWordCount()).IsEqualTo(8); } } [Test] - public async Task GetBookWordCount_SumsAllChapters() + public async Task SeveralMappingsOfOneFile_CountOnceAndShareStatsAcrossAllKeys() { - string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(tempDir); - try - { - File.WriteAllText(Path.Combine(tempDir, "ch1.html"), "

alpha beta

"); // 2 words - File.WriteAllText(Path.Combine(tempDir, "ch2.html"), "

gamma delta epsilon

"); // 3 words + using TempContentDirectory dir = new(); + dir.Write("a.html", "

one two three

"); + dir.Write("b.html", "

four five

"); - SiteMapping[] mappings = - [ - MakeMapping("ch1", tempDir, "ch1.html", 1, 1), - MakeMapping("ch2", tempDir, "ch2.html", 2, 1), - ]; - (WordCountService service, _) = CreateService(mappings, tempDir); + WordCountService service = await dir.BuildAsync( + Map("page-one", "a.html", 1, 1, keys: ["page-one", "page-one-alias"]), + Map("page-one-heading", "./a.html", 1, 1, order: 2), + Map("page-two", "b.html", 1, 2)); - await Assert.That(service.GetBookWordCount()).IsEqualTo(5); - } - finally + PageWordStats expectedFirst = new(3, 5, 0, 0, 5); + using (Assert.Multiple()) { - Directory.Delete(tempDir, recursive: true); + await Assert.That(service.GetPageStats("page-one")).IsEqualTo(expectedFirst); + await Assert.That(service.GetPageStats("page-one-alias")).IsEqualTo(expectedFirst); + await Assert.That(service.GetPageStats("page-one-heading")).IsEqualTo(expectedFirst); + await Assert.That(service.GetPageStats("page-two")).IsEqualTo(new PageWordStats(2, 5, 3, 3, 5)); + await Assert.That(service.GetBookWordCount()).IsEqualTo(5); } } [Test] - public async Task GetWordsBeforePage_ReturnsCorrectPrefixSum() + public async Task KeyCollision_FirstMappingInReadingOrderWins() + { + using TempContentDirectory dir = new(); + dir.Write("first.html", "

one two

"); + dir.Write("second.html", "

three four five

"); + + WordCountService service = await dir.BuildAsync( + Map("shared", "second.html", 1, 2), + Map("shared", "first.html", 1, 1)); + + await Assert.That(service.GetPageWordCount("shared")).IsEqualTo(2); + } + + [Test] + public async Task BeforeCacheIsBuilt_QueriesReturnNoData() { - string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(tempDir); - try + using TempContentDirectory dir = new(); + dir.Write("a.html", $"{Baseline}"); + + WordCountService service = dir.CreateService(NullLogger.Instance, Map("a", "a.html", 1, 1)); + + using (Assert.Multiple()) { - File.WriteAllText(Path.Combine(tempDir, "p1.html"), "

one two

"); // 2 words - File.WriteAllText(Path.Combine(tempDir, "p2.html"), "

three

"); // 1 word - - SiteMapping[] mappings = - [ - MakeMapping("p1", tempDir, "p1.html", 1, 1), - MakeMapping("p2", tempDir, "p2.html", 1, 2), - ]; - (WordCountService service, _) = CreateService(mappings, tempDir); - - await Assert.That(service.GetWordsBeforePage("p1")).IsEqualTo(0); // first page - await Assert.That(service.GetWordsBeforePage("p2")).IsEqualTo(2); // 2 words from p1 + await Assert.That(service.IsReady).IsFalse(); + await Assert.That(service.GetPageStats("a")).IsNull(); + await Assert.That(service.GetPageWordCount("a")).IsEqualTo(0); + await Assert.That(service.GetBookWordCount()).IsEqualTo(0); + await Assert.That(service.GetChapterWordCounts()).IsEmpty(); } - finally + } + + [Test] + public async Task HostStartup_BuildsTheCacheBeforeRequestsAreServed() + { + using TempContentDirectory dir = new(); + dir.Write("a.html", $"{Baseline}"); + WordCountService service = dir.CreateService(NullLogger.Instance, Map("a", "a.html", 1, 1)); + + await service.StartingAsync(CancellationToken.None); + + using (Assert.Multiple()) { - Directory.Delete(tempDir, recursive: true); + await Assert.That(service.IsReady).IsTrue(); + await Assert.That(service.GetPageWordCount("a")).IsEqualTo(BaselineWords); } } [Test] - public async Task DuplicatePageMappings_CountOnceAndShareWordOffsetsAcrossAllKeys() + public async Task Build_HonorsCancellation() + { + using TempContentDirectory dir = new(); + dir.Write("a.html", $"{Baseline}"); + WordCountService service = dir.CreateService(NullLogger.Instance, Map("a", "a.html", 1, 1)); + using CancellationTokenSource cts = new(); + await cts.CancelAsync(); + + await Assert.That(async () => await service.BuildAsync(cts.Token)).Throws(); + await Assert.That(service.IsReady).IsFalse(); + } + + // ---- Helpers ---- + + private static SiteMapping Map(string key, string relativePath, int chapter, int page, int order = 1, string[]? keys = null) => + new( + keys: [.. keys ?? [key]], + primaryKey: key, + pagePath: relativePath.Split('/', '\\').Where(s => s.Length > 0).ToArray(), + chapterNumber: chapter, + pageNumber: page, + orderOnPage: order, + chapterTitle: $"Chapter {chapter}", + rawHeading: key, + anchorId: key, + indentLevel: 0); + + /// A throwaway content root that deletes itself when disposed. + private sealed class TempContentDirectory : IDisposable { - string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(tempDir); - try + // Content lives one level below the temp root so "../outside.html" stays inside a directory we own. + private readonly string _root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + private readonly string _contentRoot; + + public TempContentDirectory() { - File.WriteAllText(Path.Combine(tempDir, "a.html"), "

one two three

"); - File.WriteAllText(Path.Combine(tempDir, "b.html"), "

four five

"); - - SiteMapping[] mappings = - [ - MakeMapping("page-one", tempDir, "a.html", 1, 1, keys: ["page-one", "page-one-alias"]), - MakeMapping("page-one-heading", tempDir, ".\\a.html", 1, 1, order: 2), - MakeMapping("page-two", tempDir, "b.html", 1, 2), - ]; - (WordCountService service, _) = CreateService(mappings, tempDir); - - await Assert.That(service.GetPageWordCount("page-one")).IsEqualTo(3); - await Assert.That(service.GetPageWordCount("page-one-alias")).IsEqualTo(3); - await Assert.That(service.GetPageWordCount("page-one-heading")).IsEqualTo(3); - await Assert.That(service.GetWordsBeforePage("page-one")).IsEqualTo(0); - await Assert.That(service.GetWordsBeforePage("page-one-alias")).IsEqualTo(0); - await Assert.That(service.GetWordsBeforePage("page-one-heading")).IsEqualTo(0); - await Assert.That(service.GetChapterStartWords("page-one-alias")).IsEqualTo(0); - await Assert.That(service.GetWordsBeforePage("page-two")).IsEqualTo(3); - await Assert.That(service.GetChapterStartWords("page-two")).IsEqualTo(3); - await Assert.That(service.GetChapterWordCount(1)).IsEqualTo(5); - await Assert.That(service.GetBookWordCount()).IsEqualTo(5); + _contentRoot = Path.Combine(_root, "content"); + Directory.CreateDirectory(_contentRoot); + } + + public string PathOf(string relativePath) => Path.Combine(_contentRoot, relativePath); + + public void Write(string relativePath, string html) => File.WriteAllText(PathOf(relativePath), html); + + public WordCountService CreateService(ILogger logger, params SiteMapping[] mappings) + { + Mock siteMappings = new(); + siteMappings.Setup(s => s.SiteMappings).Returns(mappings); + Mock environment = new(); + environment.Setup(e => e.ContentRootPath).Returns(_contentRoot); + return new WordCountService(siteMappings.Object, environment.Object, logger); } - finally + + public Task BuildAsync(params SiteMapping[] mappings) => + BuildAsync(NullLogger.Instance, mappings); + + public async Task BuildAsync(ILogger logger, params SiteMapping[] mappings) + { + WordCountService service = CreateService(logger, mappings); + await service.BuildAsync(); + return service; + } + + public void Dispose() => Directory.Delete(_root, recursive: true); + } + + private sealed class CapturingLogger : ILogger + { + public List Warnings { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { - Directory.Delete(tempDir, recursive: true); + if (logLevel == LogLevel.Warning) + { + Warnings.Add(formatter(state, exception)); + } } } } diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 5c7fd33f..c363fa62 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -271,7 +271,10 @@ private static void Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + // One instance serves both the query interface and the startup hook that builds the cache. + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddScoped(); // Add AI Chat services using configuration-driven backend selection. @@ -440,9 +443,6 @@ await context.HttpContext.Response.WriteAsync( WebApplication app = builder.Build(); - // Build the page word-count cache during startup instead of on the first content request. - _ = app.Services.GetRequiredService(); - if (profilerSkippedUnsupportedPlatform) LogSkippingUnsupportedAzureMonitorProfiler( app.Services.GetRequiredService>(), diff --git a/EssentialCSharp.Web/Services/IWordCountService.cs b/EssentialCSharp.Web/Services/IWordCountService.cs index 92567a5e..53133b36 100644 --- a/EssentialCSharp.Web/Services/IWordCountService.cs +++ b/EssentialCSharp.Web/Services/IWordCountService.cs @@ -4,32 +4,45 @@ namespace EssentialCSharp.Web.Services; /// Provides pre-computed prose word counts for content pages, enabling /// Kindle-style reading time estimates. /// +/// +/// The cache is built when the host starts. Until then ( is ) +/// every query returns "no data": returns , +/// counts are 0 and is empty. +/// public interface IWordCountService { - /// Gets the prose word count for a specific page key. - int GetPageWordCount(string pageKey); - - /// Gets the total prose word count for a chapter. - int GetChapterWordCount(int chapterNumber); - - /// Gets the total prose word count for the entire book. - int GetBookWordCount(); + /// Gets whether the word-count cache has been built. + bool IsReady { get; } /// - /// Gets the number of prose words before the given page (across the entire book). - /// Used for "words remaining in book" math. + /// Gets all word statistics for a page key, or if the key is unknown + /// (or the cache is not ready yet). A known key whose content file is missing or unreadable + /// yields stats with zero page words. /// - int GetWordsBeforePage(string pageKey); + PageWordStats? GetPageStats(string pageKey); - /// - /// Gets the number of prose words before the given page within its chapter. - /// Used for "words remaining in chapter" math. - /// - int GetChapterStartWords(string pageKey); + /// Gets the prose word count for a specific page key (0 if unknown). + int GetPageWordCount(string pageKey); + + /// Gets the total prose word count for the entire book. + int GetBookWordCount(); - /// Gets per-chapter word count summary for the book-stats API response. + /// Gets per-chapter word count summary (ordered by chapter number) for the book-stats API response. IReadOnlyList GetChapterWordCounts(); } /// Per-chapter word count summary returned by the book-stats API. public record ChapterWordCount(int ChapterNumber, int WordCount); + +/// Immutable prose word statistics for one page. +/// Prose words on the page. +/// Prose words in the page's whole chapter. +/// Prose words on all pages before this one across the book. +/// Prose words on pages before this one within its chapter. +/// Prose words in the whole book. +public sealed record PageWordStats( + int PageWords, + int ChapterWords, + int WordsBeforePage, + int ChapterStartWords, + int BookWords); diff --git a/EssentialCSharp.Web/Services/WordCountService.cs b/EssentialCSharp.Web/Services/WordCountService.cs index dd307e1a..f116ce33 100644 --- a/EssentialCSharp.Web/Services/WordCountService.cs +++ b/EssentialCSharp.Web/Services/WordCountService.cs @@ -1,167 +1,161 @@ +using System.Collections.Frozen; using HtmlAgilityPack; +using Microsoft.Extensions.Hosting; namespace EssentialCSharp.Web.Services; /// -/// Singleton service that computes prose-only word counts for all content pages -/// at startup and caches them in memory. Code blocks (<pre>, -/// <code>, <script>, <style>, and generated -/// .code-block-section listings are excluded because they are read very -/// differently from prose and would skew WPM estimates. +/// Computes prose-only word counts for all content pages when the host starts and caches +/// them in an immutable snapshot. <pre>, <script>, <style> +/// and generated .code-block-section listings are excluded because they are read very +/// differently from prose and would skew WPM estimates. Inline <code> inside prose +/// is counted, since readers do read those identifiers as part of the sentence. /// -public class WordCountService : IWordCountService +public sealed partial class WordCountService( + ISiteMappingService siteMappingService, + IWebHostEnvironment hostingEnvironment, + ILogger logger) : IWordCountService, IHostedLifecycleService { - // Tags whose text content is excluded from prose word counts. - private static readonly HashSet ExcludedTags = new(StringComparer.OrdinalIgnoreCase) - { - "pre", "code", "script", "style" - }; - - private readonly Dictionary _pageWordCounts; - private readonly Dictionary _chapterWordCounts; - private readonly int _bookWordCount; - private readonly Dictionary _wordsBeforePage; - private readonly Dictionary _chapterStartWords; - private readonly IReadOnlyList _chapterWordCountList; - private readonly Dictionary _pagesByPath; - - public WordCountService(ISiteMappingService siteMappingService, IWebHostEnvironment hostingEnvironment) + private const string ExcludedXPath = + "//pre | //script | //style | //*[contains(concat(' ', normalize-space(@class), ' '), ' code-block-section ')]"; + + private volatile Snapshot? _snapshot; + + public bool IsReady => _snapshot is not null; + + public PageWordStats? GetPageStats(string pageKey) => + _snapshot?.Pages.GetValueOrDefault(pageKey); + + public int GetPageWordCount(string pageKey) => GetPageStats(pageKey)?.PageWords ?? 0; + + public int GetBookWordCount() => _snapshot?.BookWords ?? 0; + + public IReadOnlyList GetChapterWordCounts() => _snapshot?.Chapters ?? []; + + /// + /// Reads every mapped content page and publishes the word-count snapshot. A page that is + /// missing or cannot be parsed is logged and counted as zero words; it never fails the build. + /// + public Task BuildAsync(CancellationToken cancellationToken = default) { - _pageWordCounts = []; - _chapterWordCounts = []; - _wordsBeforePage = []; - _chapterStartWords = []; - _pagesByPath = new Dictionary( - OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); - - // Walk mappings in canonical reading order: chapter → page → order-on-page. + // Walk mappings in canonical reading order: chapter -> page -> order-on-page. IEnumerable orderedMappings = siteMappingService.SiteMappings .OrderBy(m => m.ChapterNumber) .ThenBy(m => m.PageNumber) .ThenBy(m => m.OrderOnPage); - int bookWords = 0; - int currentChapter = -1; - int chapterWords = 0; + // One entry per distinct file, in reading order. Later mappings of the same file only add keys. + Dictionary filesByPath = new(StringComparer.Ordinal); + List files = []; + Dictionary fileByKey = new(StringComparer.Ordinal); foreach (SiteMapping mapping in orderedMappings) { - string filePath = Path.GetFullPath(Path.Join( - hostingEnvironment.ContentRootPath, - Path.Join(mapping.PagePath))); + cancellationToken.ThrowIfCancellationRequested(); - if (_pagesByPath.TryGetValue(filePath, out PageWordData? existingPage)) + string? path = ResolvePath(mapping); + if (path is null) { - MapKeys(mapping, existingPage); + LogPathEscapesRoot(logger, (mapping.Keys ?? []).FirstOrDefault() ?? mapping.PrimaryKey); continue; } - // Chapter boundary: flush previous chapter totals. - if (mapping.ChapterNumber != currentChapter) + if (!filesByPath.TryGetValue(path, out FileEntry? entry)) + { + entry = new FileEntry(mapping.ChapterNumber, CountProseWords(path)); + filesByPath[path] = entry; + files.Add(entry); + } + + foreach (string key in (mapping.Keys ?? []).Append(mapping.PrimaryKey)) { - if (currentChapter >= 0) + // First mapping wins if the sitemap contains a key collision. + if (!string.IsNullOrWhiteSpace(key)) { - _chapterWordCounts[currentChapter] = chapterWords; + fileByKey.TryAdd(key, entry); } - currentChapter = mapping.ChapterNumber; - chapterWords = 0; } - - int words = CountProseWords(filePath); - PageWordData page = new(words, bookWords, chapterWords); - _pagesByPath[filePath] = page; - MapKeys(mapping, page); - - bookWords += words; - chapterWords += words; } - // Flush the last chapter. - if (currentChapter >= 0) + Dictionary chapterTotals = []; + foreach (FileEntry file in files) { - _chapterWordCounts[currentChapter] = chapterWords; + chapterTotals[file.Chapter] = chapterTotals.GetValueOrDefault(file.Chapter) + file.Words; } + int bookWords = chapterTotals.Values.Sum(); - _bookWordCount = bookWords; - _chapterWordCountList = _chapterWordCounts - .OrderBy(kv => kv.Key) - .Select(kv => new ChapterWordCount(kv.Key, kv.Value)) - .ToList() - .AsReadOnly(); - } - - public int GetPageWordCount(string pageKey) => - _pageWordCounts.TryGetValue(pageKey, out int count) ? count : 0; - - public int GetChapterWordCount(int chapterNumber) => - _chapterWordCounts.TryGetValue(chapterNumber, out int count) ? count : 0; + Dictionary statsByFile = new(ReferenceEqualityComparer.Instance); + Dictionary chapterRunning = []; + int wordsBefore = 0; + foreach (FileEntry file in files) + { + int chapterStart = chapterRunning.GetValueOrDefault(file.Chapter); + statsByFile[file] = new PageWordStats(file.Words, chapterTotals[file.Chapter], wordsBefore, chapterStart, bookWords); + chapterRunning[file.Chapter] = chapterStart + file.Words; + wordsBefore += file.Words; + } - public int GetBookWordCount() => _bookWordCount; + _snapshot = new Snapshot( + fileByKey.ToFrozenDictionary(kv => kv.Key, kv => statsByFile[kv.Value], StringComparer.Ordinal), + [.. chapterTotals.OrderBy(kv => kv.Key).Select(kv => new ChapterWordCount(kv.Key, kv.Value))], + bookWords); - public int GetWordsBeforePage(string pageKey) => - _wordsBeforePage.TryGetValue(pageKey, out int count) ? count : 0; + return Task.CompletedTask; + } - public int GetChapterStartWords(string pageKey) => - _chapterStartWords.TryGetValue(pageKey, out int count) ? count : 0; + // ---- Hosting: build before the host starts accepting requests ---- - public IReadOnlyList GetChapterWordCounts() => _chapterWordCountList; + public Task StartingAsync(CancellationToken cancellationToken) => BuildAsync(cancellationToken); + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask; - // --- + // ---- Implementation ---- - private void MapKeys(SiteMapping mapping, PageWordData page) + /// Returns a normalized, '/'-separated absolute path, or null if it leaves the content root. + private string? ResolvePath(SiteMapping mapping) { - IEnumerable pageKeys = (mapping.Keys ?? []) - .Append(mapping.PrimaryKey) - .Where(key => !string.IsNullOrWhiteSpace(key)) - .Distinct(StringComparer.Ordinal); + string root = Path.GetFullPath(hostingEnvironment.ContentRootPath); + string full = Path.GetFullPath(Path.Join(root, Path.Join(mapping.PagePath))); + string relative = Path.GetRelativePath(root, full); + return relative.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relative) + ? null + : full.Replace('\\', '/'); + } - foreach (string pageKey in pageKeys) + private int CountProseWords(string filePath) + { + try { - // Keep the first mapping for a route key if the sitemap contains a collision. - if (_pageWordCounts.ContainsKey(pageKey)) + if (!File.Exists(filePath)) { - continue; + LogFileMissing(logger, filePath); + return 0; } - _pageWordCounts[pageKey] = page.WordCount; - _wordsBeforePage[pageKey] = page.WordsBeforePage; - _chapterStartWords[pageKey] = page.ChapterStartWords; - } - } + HtmlDocument doc = new(); + doc.Load(filePath); - private static int CountProseWords(string filePath) - { - if (!File.Exists(filePath)) - { - return 0; - } - - HtmlDocument doc = new(); - doc.Load(filePath); + HtmlNode root = doc.DocumentNode.SelectSingleNode("//body") ?? doc.DocumentNode; - HtmlNode? body = doc.DocumentNode.SelectSingleNode("//body") ?? doc.DocumentNode; + foreach (HtmlNode node in root.SelectNodes(ExcludedXPath)?.ToList() ?? []) + { + node.Remove(); + } - // Remove excluded tags (modifies in-place on a clone isn't available, so remove from live tree). - string xpath = string.Join( - " | ", - ExcludedTags.Select(t => $"//{t}") - .Append("//*[contains(concat(' ', normalize-space(@class), ' '), ' code-block-section ')]")); - foreach (HtmlNode node in body.SelectNodes(xpath)?.ToList() ?? []) + return CountWords(HtmlEntity.DeEntitize(root.InnerText)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or InvalidOperationException) { - node.Remove(); + LogFileUnreadable(logger, ex, filePath); + return 0; } - - string text = body.InnerText; - return CountWords(text); } private static int CountWords(string text) { - if (string.IsNullOrWhiteSpace(text)) - { - return 0; - } - int count = 0; bool inWord = false; foreach (char c in text) @@ -179,5 +173,19 @@ private static int CountWords(string text) return count; } - private sealed record PageWordData(int WordCount, int WordsBeforePage, int ChapterStartWords); + [LoggerMessage(Level = LogLevel.Warning, Message = "Skipping word count for mapping '{Key}': page path escapes the content root.")] + private static partial void LogPathEscapesRoot(ILogger logger, string? key); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Content file '{FilePath}' not found; counting it as 0 words.")] + private static partial void LogFileMissing(ILogger logger, string filePath); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Could not read content file '{FilePath}'; counting it as 0 words.")] + private static partial void LogFileUnreadable(ILogger logger, Exception exception, string filePath); + + private sealed record FileEntry(int Chapter, int Words); + + private sealed record Snapshot( + FrozenDictionary Pages, + IReadOnlyList Chapters, + int BookWords); } From f07f437643f475a43978a8497f6f8ba6ada028db Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Thu, 5 Nov 2026 21:07:28 -0800 Subject: [PATCH 11/38] refactor(reading): emit window.READING_CONTEXT via view component Removes the reading ViewBag entries and window globals; csrf-token meta is only rendered for signed-in users. --- .../ReadingContextRenderingTests.cs | 87 +++++++++++++++++++ .../Controllers/HomeController.cs | 8 +- .../ReadingContextViewComponent.cs | 43 +++++++++ .../Components/ReadingContext/Default.cshtml | 4 + .../Views/Shared/_Layout.cshtml | 14 +-- 5 files changed, 143 insertions(+), 13 deletions(-) create mode 100644 EssentialCSharp.Web.Tests/ReadingContextRenderingTests.cs create mode 100644 EssentialCSharp.Web/ViewComponents/ReadingContextViewComponent.cs create mode 100644 EssentialCSharp.Web/Views/Shared/Components/ReadingContext/Default.cshtml diff --git a/EssentialCSharp.Web.Tests/ReadingContextRenderingTests.cs b/EssentialCSharp.Web.Tests/ReadingContextRenderingTests.cs new file mode 100644 index 00000000..49d6b7b2 --- /dev/null +++ b/EssentialCSharp.Web.Tests/ReadingContextRenderingTests.cs @@ -0,0 +1,87 @@ +using System.Net; + +namespace EssentialCSharp.Web.Tests; + +/// +/// The layout hands the reading-time client its data through window.READING_CONTEXT (content pages only) +/// and a csrf-token meta tag (signed-in users only). +/// +public class ReadingContextRenderingTests : IntegrationTestBase +{ + private const string ContentPage = "/hello-world"; + + [Test] + public async Task ContentPage_RendersReadingContextWithCamelCaseFields() + { + using HttpClient client = CreateClientWithoutAutoRedirect(); + + string html = await GetOkHtmlAsync(client, ContentPage); + + using (Assert.Multiple()) + { + await Assert.That(html).Contains("window.READING_CONTEXT = {"); + await Assert.That(html).Contains("\"isAuthenticated\":false"); + await Assert.That(html).Contains("\"pageKey\":\"hello-world\""); + await Assert.That(html).Contains("\"pageWords\":"); + await Assert.That(html).Contains("\"chapterWords\":"); + await Assert.That(html).Contains("\"wordsBeforePage\":"); + await Assert.That(html).Contains("\"chapterStartWords\":"); + await Assert.That(html).Contains("\"bookWords\":"); + } + } + + [Test] + [Arguments("/about")] + [Arguments("/Identity/Account/Login")] + public async Task NonContentPage_DoesNotRenderReadingContext(string path) + { + using HttpClient client = CreateClientWithoutAutoRedirect(); + + string html = await GetOkHtmlAsync(client, path); + + await Assert.That(html).DoesNotContain("READING_CONTEXT"); + } + + [Test] + public async Task AnonymousVisitor_GetsNoCsrfMetaAndNoAntiforgeryCookie() + { + using HttpClient client = CreateClientWithoutAutoRedirect(); + + using HttpResponseMessage response = await client.GetAsync(ContentPage); + string html = await response.Content.ReadAsStringAsync(); + response.Headers.TryGetValues("Set-Cookie", out IEnumerable? cookies); + + using (Assert.Multiple()) + { + await Assert.That(html).DoesNotContain("name=\"csrf-token\""); + await Assert.That(cookies ?? []).DoesNotContain(c => c.Contains("Antiforgery", StringComparison.OrdinalIgnoreCase)); + } + } + + [Test] + public async Task SignedInUser_GetsCsrfMetaAndAuthenticatedReadingContext() + { + string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-context"); + (string cookieName, string cookieValue) = await McpTestHelper.CreateIdentityApplicationCookieAsync(Factory, userId); + using HttpClient client = CreateClientWithoutAutoRedirect(); + using HttpRequestMessage request = new(HttpMethod.Get, ContentPage); + McpTestHelper.AddCookie(request, cookieName, cookieValue); + + using HttpResponseMessage response = await client.SendAsync(request); + string html = await response.Content.ReadAsStringAsync(); + + using (Assert.Multiple()) + { + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That(html).Contains("name=\"csrf-token\""); + await Assert.That(html).Contains("\"isAuthenticated\":true"); + } + } + + private static async Task GetOkHtmlAsync(HttpClient client, string path) + { + using HttpResponseMessage response = await GetFollowingGetRedirectsAsync(client, path); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + return await response.Content.ReadAsStringAsync(); + } +} diff --git a/EssentialCSharp.Web/Controllers/HomeController.cs b/EssentialCSharp.Web/Controllers/HomeController.cs index 1d593747..16da1e5f 100644 --- a/EssentialCSharp.Web/Controllers/HomeController.cs +++ b/EssentialCSharp.Web/Controllers/HomeController.cs @@ -13,7 +13,7 @@ namespace EssentialCSharp.Web.Controllers; -public class HomeController(ILogger logger, IWebHostEnvironment hostingEnvironment, ISiteMappingService siteMappingService, ChapterDescriptionService chapterDescriptionService, IHttpContextAccessor httpContextAccessor, IRouteConfigurationService routeConfigurationService, IOptions siteSettings, IWordCountService wordCountService) : BaseController(routeConfigurationService, httpContextAccessor) +public class HomeController(ILogger logger, IWebHostEnvironment hostingEnvironment, ISiteMappingService siteMappingService, ChapterDescriptionService chapterDescriptionService, IHttpContextAccessor httpContextAccessor, IRouteConfigurationService routeConfigurationService, IOptions siteSettings) : BaseController(routeConfigurationService, httpContextAccessor) { [EnableRateLimiting("content")] public IActionResult Index() @@ -60,6 +60,7 @@ public IActionResult Index() string headHtml = doc.DocumentNode.Element("html")?.Element("head")?.InnerHtml ?? string.Empty; string html = doc.DocumentNode.Element("html")?.Element("body")?.InnerHtml ?? string.Empty; + // PrimaryKey is not serialized in sitemap.json, so it is null after deserialization; Keys is authoritative. string pageKey = siteMapping.Keys.FirstOrDefault() ?? siteMapping.PrimaryKey ?? string.Empty; ViewBag.PageTitle = siteMapping.IndentLevel is 0 ? siteMapping.ChapterTitle + " " + siteMapping.RawHeading : siteMapping.RawHeading; ViewData["Description"] = chapterDescriptionService.GetDescription(siteMapping); @@ -70,11 +71,6 @@ public IActionResult Index() ViewBag.HeadContents = headHtml; ViewBag.Contents = html; ViewBag.NeedsPageHeading = doc.DocumentNode.SelectSingleNode("//body//h1") is null; - ViewBag.PageWordCount = wordCountService.GetPageWordCount(pageKey); - ViewBag.ChapterWordCount = wordCountService.GetChapterWordCount(siteMapping.ChapterNumber); - ViewBag.WordsBeforePage = wordCountService.GetWordsBeforePage(pageKey); - ViewBag.ChapterStartWords = wordCountService.GetChapterStartWords(pageKey); - ViewBag.BookWordCount = wordCountService.GetBookWordCount(); return View(); } else diff --git a/EssentialCSharp.Web/ViewComponents/ReadingContextViewComponent.cs b/EssentialCSharp.Web/ViewComponents/ReadingContextViewComponent.cs new file mode 100644 index 00000000..a1baec1c --- /dev/null +++ b/EssentialCSharp.Web/ViewComponents/ReadingContextViewComponent.cs @@ -0,0 +1,43 @@ +using EssentialCSharp.Web.Areas.Identity.Data; +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace EssentialCSharp.Web.ViewComponents; + +/// +/// Emits window.READING_CONTEXT for the reading-time tracker. Renders nothing on pages that +/// are not book content (no ViewBag.CurrentPageKey), such as Identity pages and errors. +/// +public class ReadingContextViewComponent( + IWordCountService wordCountService, + SignInManager signInManager) : ViewComponent +{ + public IViewComponentResult Invoke() + { + if (ViewData["CurrentPageKey"] is not string { Length: > 0 } pageKey) + { + return Content(string.Empty); + } + + PageWordStats? stats = wordCountService.GetPageStats(pageKey); + return View(new ReadingContextViewModel( + IsAuthenticated: signInManager.IsSignedIn(UserClaimsPrincipal), + PageKey: pageKey, + PageWords: stats?.PageWords ?? 0, + ChapterWords: stats?.ChapterWords ?? 0, + WordsBeforePage: stats?.WordsBeforePage ?? 0, + ChapterStartWords: stats?.ChapterStartWords ?? 0, + BookWords: stats?.BookWords ?? wordCountService.GetBookWordCount())); + } +} + +/// Serialized (camelCase) as window.READING_CONTEXT; property names are a contract with the client. +public record ReadingContextViewModel( + bool IsAuthenticated, + string PageKey, + int PageWords, + int ChapterWords, + int WordsBeforePage, + int ChapterStartWords, + int BookWords); diff --git a/EssentialCSharp.Web/Views/Shared/Components/ReadingContext/Default.cshtml b/EssentialCSharp.Web/Views/Shared/Components/ReadingContext/Default.cshtml new file mode 100644 index 00000000..585baea1 --- /dev/null +++ b/EssentialCSharp.Web/Views/Shared/Components/ReadingContext/Default.cshtml @@ -0,0 +1,4 @@ +@model EssentialCSharp.Web.ViewComponents.ReadingContextViewModel + diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml index 5460cbed..c1e378be 100644 --- a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml +++ b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml @@ -30,7 +30,12 @@ } - + @if (SignInManager.IsSignedIn(User)) + { + // Only signed-in users call the antiforgery-protected reading API; GetAndStoreTokens sets + // cookie/no-cache headers, so never call it for anonymous visitors on public pages. + + } @if (Context.Response.StatusCode < 400 && !Context.Request.Path.StartsWithSegments("/Identity")) @@ -224,13 +229,8 @@ window.BUILD_LABEL = @Json.Serialize(buildLabel); window.ENABLE_CHAT_WIDGET = @Json.Serialize(!Context.Request.Path.StartsWithSegments("/Identity")); window.HCAPTCHA_SITE_KEY = @Json.Serialize(chatCaptchaSiteKey); - window.CURRENT_PAGE_KEY = @Json.Serialize(ViewBag.CurrentPageKey); - window.PAGE_WORD_COUNT = @Json.Serialize(ViewBag.PageWordCount ?? 0); - window.CHAPTER_WORD_COUNT = @Json.Serialize(ViewBag.ChapterWordCount ?? 0); - window.WORDS_BEFORE_PAGE = @Json.Serialize(ViewBag.WordsBeforePage ?? 0); - window.CHAPTER_START_WORDS = @Json.Serialize(ViewBag.ChapterStartWords ?? 0); - window.BOOK_WORD_COUNT = @Json.Serialize(ViewBag.BookWordCount ?? 0); + @await Component.InvokeAsync("ReadingContext") From b99f4882c776f7003cfc8f70c32210359e37199b Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Thu, 5 Nov 2026 21:09:52 -0800 Subject: [PATCH 12/38] test(reading): test requirements, add policy unit tests and broader coverage --- .../ReadingControllerTests.cs | 663 ++++++++++++------ .../ReadingSpeedPolicyTests.cs | 110 +++ 2 files changed, 558 insertions(+), 215 deletions(-) create mode 100644 EssentialCSharp.Web.Tests/ReadingSpeedPolicyTests.cs diff --git a/EssentialCSharp.Web.Tests/ReadingControllerTests.cs b/EssentialCSharp.Web.Tests/ReadingControllerTests.cs index 0e27cf9f..c9e26189 100644 --- a/EssentialCSharp.Web.Tests/ReadingControllerTests.cs +++ b/EssentialCSharp.Web.Tests/ReadingControllerTests.cs @@ -1,354 +1,587 @@ using System.Net; using System.Net.Http.Json; -using System.Text.RegularExpressions; +using System.Text; +using System.Text.Json; +using EssentialCSharp.Web.Areas.Identity.Data; using EssentialCSharp.Web.Controllers; using EssentialCSharp.Web.Data; using EssentialCSharp.Web.Models; +using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; namespace EssentialCSharp.Web.Tests; public class ReadingControllerTests : IntegrationTestBase { + // Baseline profile: 1200 words in 360 seconds = 200 WPM. + private const long BaselineWords = 1200; + private const long BaselineSeconds = 360; + private const double BaselineWpm = 200; + + // The content package is a placeholder in many environments, so use fixed, known page word counts. + private static readonly Dictionary PageWordCounts = new() + { + ["reading-test-small"] = 150, + ["reading-test-large"] = 5000 + }; + + private static readonly ChapterWordCount[] ChapterWordCounts = + [ + new(1, 3000), + new(2, 2150) + ]; + + protected override void ConfigureTestServices(IServiceCollection services) + { + services.RemoveAll(); + services.AddSingleton(new FakeWordCountService()); + } + + private sealed class FakeWordCountService : IWordCountService + { + public int GetPageWordCount(string pageKey) => PageWordCounts.GetValueOrDefault(pageKey); + public int GetChapterWordCount(int chapterNumber) => + ChapterWordCounts.Where(c => c.ChapterNumber == chapterNumber).Sum(c => c.WordCount); + public int GetBookWordCount() => ChapterWordCounts.Sum(c => c.WordCount); + public int GetWordsBeforePage(string pageKey) => 0; + public int GetChapterStartWords(string pageKey) => 0; + public IReadOnlyList GetChapterWordCounts() => ChapterWordCounts; + } + + private ReadingOptions Options => + Factory.Services.GetRequiredService>().Value; + + // ---- authentication ---- + [Test] - public async Task GetBookStats_IsPublic_Returns200() + [Arguments("GET", "/api/reading/profile")] + [Arguments("POST", "/api/reading/session")] + [Arguments("POST", "/api/reading/reset")] + public async Task ProtectedEndpoints_Anonymous_Return401WithoutRedirect(string method, string url) { + PageInfo page = GetLargestPage(); using HttpClient client = CreateClientWithoutAutoRedirect(); - using HttpResponseMessage response = await client.GetAsync("/api/reading/book-stats"); - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + using var request = new HttpRequestMessage(new HttpMethod(method), url); + if (method == "POST") + { + request.Content = JsonBody($$"""{"intervals":[{"pageKey":"{{page.Key}}","wordsRead":10,"activeSeconds":10}]}"""); + } + + using HttpResponseMessage response = await client.SendAsync(request); + + using (Assert.Multiple()) + { + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + await Assert.That(response.Headers.Location).IsNull(); + } } [Test] - public async Task GetBookStats_ReturnsExpectedShape() + [Arguments("/api/reading/session")] + [Arguments("/api/reading/reset")] + public async Task WriteEndpoints_WithoutAntiforgeryToken_AreRejectedAndDoNotChangeProfile(string url) { - using HttpClient client = CreateClientWithoutAutoRedirect(); - using HttpResponseMessage response = await client.GetAsync("/api/reading/book-stats"); + PageInfo page = GetLargestPage(); + string userId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(userId, includeAntiforgery: false); + using var request = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = JsonBody($$"""{"intervals":[{"pageKey":"{{page.Key}}","wordsRead":10,"activeSeconds":10}]}""") + }; - var body = await response.Content.ReadFromJsonAsync(); - await Assert.That(body).IsNotNull(); - await Assert.That(body.TotalWordCount).IsGreaterThanOrEqualTo(0); - await Assert.That(body.Chapters).IsNotNull(); + using HttpResponseMessage response = await client.SendAsync(request); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await AssertProfileAsync(userId, BaselineWords, BaselineSeconds); } - // ---- profile (requires auth) ---- + // ---- GET profile ---- [Test] - public async Task GetProfile_Anonymous_Returns401() + public async Task GetProfile_UserWithoutData_ReturnsZeroTotalsAndNullWpm() { - using HttpClient client = CreateClientWithoutAutoRedirect(); + string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-empty"); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + using HttpResponseMessage response = await client.GetAsync("/api/reading/profile"); - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); - } - // ---- POST session (requires auth) ---- + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + JsonElement body = await response.Content.ReadFromJsonAsync(); + using (Assert.Multiple()) + { + await Assert.That(body.GetProperty("totalWordsRead").GetInt64()).IsEqualTo(0L); + await Assert.That(body.GetProperty("totalActiveSeconds").GetInt64()).IsEqualTo(0L); + await Assert.That(body.GetProperty("wpm").ValueKind).IsEqualTo(JsonValueKind.Null); + } + } [Test] - public async Task PostSession_Anonymous_Returns401() + public async Task GetProfile_UserWithData_ReturnsTotalsAndDerivedWpm() { - using HttpClient client = CreateClientWithoutAutoRedirect(); - var intervals = new[] + string userId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + using HttpResponseMessage response = await client.GetAsync("/api/reading/profile"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + JsonElement body = await response.Content.ReadFromJsonAsync(); + using (Assert.Multiple()) { - new ReadingController.ReadingIntervalDto("page1", 60, 100, false) - }; - using HttpResponseMessage response = await client.PostAsJsonAsync("/api/reading/session", intervals); - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + await Assert.That(body.GetProperty("totalWordsRead").GetInt64()).IsEqualTo(BaselineWords); + await Assert.That(body.GetProperty("totalActiveSeconds").GetInt64()).IsEqualTo(BaselineSeconds); + await Assert.That(body.GetProperty("wpm").GetDouble()).IsEqualTo(BaselineWpm).Within(0.001); + } } - // ---- POST reset (requires auth) ---- + // ---- POST session: scoring behavior ---- [Test] - public async Task PostReset_Anonymous_Returns401() + public async Task PostSession_NormalInterval_AddsWordsAndTimeAndReturnsUpdatedProfile() { - using HttpClient client = CreateClientWithoutAutoRedirect(); - using HttpResponseMessage response = await client.PostAsync("/api/reading/reset", null); - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + PageInfo page = GetLargestPage(); + string userId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + int words = Math.Min(page.WordCount, 100); + int seconds = 30; + + using HttpResponseMessage response = await PostSessionAsync(client, Interval(page.Key, words, seconds)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + JsonElement body = await response.Content.ReadFromJsonAsync(); + long expectedWords = BaselineWords + words; + long expectedSeconds = BaselineSeconds + seconds; + using (Assert.Multiple()) + { + await Assert.That(body.GetProperty("totalWordsRead").GetInt64()).IsEqualTo(expectedWords); + await Assert.That(body.GetProperty("totalActiveSeconds").GetInt64()).IsEqualTo(expectedSeconds); + await Assert.That(body.GetProperty("wpm").GetDouble()) + .IsEqualTo(expectedWords / (expectedSeconds / 60.0)).Within(0.001); + } + await AssertProfileAsync(userId, expectedWords, expectedSeconds); } [Test] - public async Task PostSession_IntervalAboveHardCutoff_DoesNotChangePersistedProfile() + public async Task PostSession_VerySlowInterval_CountsForLessThanItsWallClockTime() { + PageInfo page = GetLargestPage(); string userId = await CreateUserWithBaselineProfileAsync(); using HttpClient client = await CreateAuthenticatedClientAsync(userId); - using var request = CreateSessionRequest( - [new ReadingController.ReadingIntervalDto("fast-page", 60, 901, false)]); + int words = Math.Min(page.WordCount, 50); + int wallClockSeconds = 3600; - using HttpResponseMessage response = await client.SendAsync(request); + using HttpResponseMessage response = await PostSessionAsync(client, Interval(page.Key, words, wallClockSeconds)); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); UserReadingProfile profile = await GetProfileAsync(userId); - await Assert.That(profile.TotalWordsRead).IsEqualTo(1200L); - await Assert.That(profile.TotalActiveSeconds).IsEqualTo(360L); - await Assert.That(profile.DeriveWpm()).IsEqualTo(200.0); - await Assert.That(await GetActivityCountAsync(userId)).IsEqualTo(0); + long creditedSeconds = profile.TotalActiveSeconds - BaselineSeconds; + using (Assert.Multiple()) + { + await Assert.That(profile.TotalWordsRead).IsEqualTo(BaselineWords + words); + await Assert.That(creditedSeconds).IsGreaterThan(0L); + await Assert.That(creditedSeconds).IsLessThan((long)wallClockSeconds); + // Credited at roughly the slow-outlier floor speed. + double creditedWpm = words / (creditedSeconds / 60.0); + await Assert.That(creditedWpm).IsEqualTo(Options.SlowOutlierFactor * BaselineWpm).Within(1.0); + } } [Test] - public async Task PostSession_SlowInterval_ClampsTimeInPersistedProfile() + public async Task PostSession_IntervalAtHardCutoff_IsAccepted() { + PageInfo page = GetLargestPage(); + int seconds = 20; + int words = (int)(Options.HardCutoffWpm * seconds / 60.0); + await Assert.That(page.WordCount).IsGreaterThanOrEqualTo(words + 1); string userId = await CreateUserWithBaselineProfileAsync(); using HttpClient client = await CreateAuthenticatedClientAsync(userId); - using var request = CreateSessionRequest( - [new ReadingController.ReadingIntervalDto("slow-page", 300, 10, false)]); - using HttpResponseMessage response = await client.SendAsync(request); + using HttpResponseMessage response = await PostSessionAsync(client, Interval(page.Key, words, seconds)); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); - UserReadingProfile profile = await GetProfileAsync(userId); - await Assert.That(profile.TotalWordsRead).IsEqualTo(1210L); - await Assert.That(profile.TotalActiveSeconds).IsEqualTo(372L); - await Assert.That( - Math.Abs(profile.DeriveWpm()!.Value - 1210.0 / (372.0 / 60.0)) < 0.001).IsTrue(); - - ReadingActivity activity = await GetSingleActivityAsync(userId); - await Assert.That(activity.PageKey).IsEqualTo("slow-page"); - await Assert.That(activity.WordsRead).IsEqualTo(10); - await Assert.That(activity.ActiveSeconds).IsEqualTo(300); + await AssertProfileAsync(userId, BaselineWords + words, BaselineSeconds + seconds); } [Test] - public async Task PostSession_SmallSlowInterval_UsesAtLeastOneClampedSecond() + public async Task PostSession_ImplausiblyFastInterval_IsIgnoredAndStillSucceeds() { + PageInfo page = GetLargestPage(); + int seconds = 20; + int words = (int)(Options.HardCutoffWpm * seconds / 60.0) + 1; + await Assert.That(page.WordCount).IsGreaterThanOrEqualTo(words); string userId = await CreateUserWithBaselineProfileAsync(); using HttpClient client = await CreateAuthenticatedClientAsync(userId); - using var request = CreateSessionRequest( - [new ReadingController.ReadingIntervalDto("tiny-slow-page", 300, 1, false)]); - using HttpResponseMessage response = await client.SendAsync(request); + using HttpResponseMessage response = await PostSessionAsync(client, Interval(page.Key, words, seconds)); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); - UserReadingProfile profile = await GetProfileAsync(userId); - await Assert.That(profile.TotalWordsRead).IsEqualTo(1201L); - await Assert.That(profile.TotalActiveSeconds).IsEqualTo(362L); - ReadingActivity activity = await GetSingleActivityAsync(userId); - await Assert.That(activity.WordsRead).IsEqualTo(1); + await AssertProfileAsync(userId, BaselineWords, BaselineSeconds); } [Test] - public async Task PostSession_NormalInterval_AddsUnmodifiedWordsAndTimeToPersistedProfile() + public async Task PostSession_WordsAbovePageWordCount_AreCappedAtPageWordCount() { - string userId = await CreateUserWithBaselineProfileAsync(); + PageInfo page = GetSmallestPage(); + string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-cap"); using HttpClient client = await CreateAuthenticatedClientAsync(userId); - using var request = CreateSessionRequest( - [new ReadingController.ReadingIntervalDto("normal-page", 60, 180, true)]); + int claimedWords = page.WordCount + 1; + // Long enough that the interval stays below the hard cutoff. + int seconds = (int)Math.Ceiling(claimedWords * 60.0 / Options.HardCutoffWpm) + 5; - using HttpResponseMessage response = await client.SendAsync(request); + using HttpResponseMessage response = await PostSessionAsync(client, Interval(page.Key, claimedWords, seconds)); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); UserReadingProfile profile = await GetProfileAsync(userId); - await Assert.That(profile.TotalWordsRead).IsEqualTo(1380L); - await Assert.That(profile.TotalActiveSeconds).IsEqualTo(420L); - await Assert.That( - Math.Abs(profile.DeriveWpm()!.Value - 1380.0 / (420.0 / 60.0)) < 0.001).IsTrue(); - - ReadingActivity activity = await GetSingleActivityAsync(userId); - await Assert.That(activity.PageKey).IsEqualTo("normal-page"); - await Assert.That(activity.WordsRead).IsEqualTo(180); - await Assert.That(activity.ActiveSeconds).IsEqualTo(60); - await Assert.That(activity.Completed).IsTrue(); + await Assert.That(profile.TotalWordsRead).IsEqualTo((long)page.WordCount); + } + + [Test] + public async Task PostSession_AtMaximumIntervalsPerRequest_IsAccepted() + { + PageInfo page = GetLargestPage(); + string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-batch-max"); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + int count = Options.MaxIntervalsPerRequest; + + using HttpResponseMessage response = await PostSessionAsync( + client, + Enumerable.Repeat(Interval(page.Key, 10, 10), count).ToArray()); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await AssertProfileAsync(userId, 10L * count, 10L * count); } [Test] - public async Task PostSession_RejectsMoreThanMaximumIntervalBatch() + public async Task PostSession_OneOverMaximumIntervalsPerRequest_IsRejectedAndDoesNotChangeProfile() { + PageInfo page = GetLargestPage(); string userId = await CreateUserWithBaselineProfileAsync(); using HttpClient client = await CreateAuthenticatedClientAsync(userId); - ReadingController.ReadingIntervalDto[] intervals = Enumerable - .Range(0, 51) - .Select(index => new ReadingController.ReadingIntervalDto($"page-{index}", 60, 100, false)) - .ToArray(); - using var request = CreateSessionRequest(intervals); + int count = Options.MaxIntervalsPerRequest + 1; - using HttpResponseMessage response = await client.SendAsync(request); + using HttpResponseMessage response = await PostSessionAsync( + client, + Enumerable.Repeat(Interval(page.Key, 10, 10), count).ToArray()); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); - UserReadingProfile profile = await GetProfileAsync(userId); - await Assert.That(profile.TotalWordsRead).IsEqualTo(1200L); - await Assert.That(profile.TotalActiveSeconds).IsEqualTo(360L); - await Assert.That(await GetActivityCountAsync(userId)).IsEqualTo(0); + await AssertProfileAsync(userId, BaselineWords, BaselineSeconds); + } + + [Test] + public async Task PostSession_PageKeyLength_AtLimitIsUnknownKeyAndOneOverFailsLengthValidation() + { + string userId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + using HttpResponseMessage atLimit = await PostSessionAsync( + client, Interval(new string('p', ReadingIntervalRequest.PageKeyMaxLength), 10, 10)); + using HttpResponseMessage overLimit = await PostSessionAsync( + client, Interval(new string('p', ReadingIntervalRequest.PageKeyMaxLength + 1), 10, 10)); + + string atLimitBody = await atLimit.Content.ReadAsStringAsync(); + string overLimitBody = await overLimit.Content.ReadAsStringAsync(); + using (Assert.Multiple()) + { + await Assert.That(atLimit.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(atLimitBody).Contains("Unknown page key"); + await Assert.That(overLimit.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(overLimitBody).DoesNotContain("Unknown page key"); + } + await AssertProfileAsync(userId, BaselineWords, BaselineSeconds); } + // ---- POST session: invalid input ---- + [Test] - public async Task PostSession_RejectsPageKeyLongerThanPersistenceLimit() + [Arguments("empty body", "")] + [Arguments("JSON null", "null")] + [Arguments("malformed JSON", "{not json")] + [Arguments("bare array (old wire format)", """[{"pageKey":"{KEY}","wordsRead":10,"activeSeconds":10}]""")] + [Arguments("missing intervals", "{}")] + [Arguments("null intervals", """{"intervals":null}""")] + [Arguments("empty intervals", """{"intervals":[]}""")] + [Arguments("null interval entry", """{"intervals":[null]}""")] + [Arguments("zero words", """{"intervals":[{"pageKey":"{KEY}","wordsRead":0,"activeSeconds":10}]}""")] + [Arguments("negative words", """{"intervals":[{"pageKey":"{KEY}","wordsRead":-1,"activeSeconds":10}]}""")] + [Arguments("zero seconds", """{"intervals":[{"pageKey":"{KEY}","wordsRead":10,"activeSeconds":0}]}""")] + [Arguments("negative seconds", """{"intervals":[{"pageKey":"{KEY}","wordsRead":10,"activeSeconds":-1}]}""")] + [Arguments("int.MaxValue words", """{"intervals":[{"pageKey":"{KEY}","wordsRead":2147483647,"activeSeconds":10}]}""")] + [Arguments("int.MaxValue seconds", """{"intervals":[{"pageKey":"{KEY}","wordsRead":10,"activeSeconds":2147483647}]}""")] + [Arguments("missing page key", """{"intervals":[{"wordsRead":10,"activeSeconds":10}]}""")] + [Arguments("empty page key", """{"intervals":[{"pageKey":"","wordsRead":10,"activeSeconds":10}]}""")] + [Arguments("unknown page key", """{"intervals":[{"pageKey":"no-such-page","wordsRead":10,"activeSeconds":10}]}""")] + [Arguments("one bad interval among good ones", """{"intervals":[{"pageKey":"{KEY}","wordsRead":10,"activeSeconds":10},{"pageKey":"{KEY}","wordsRead":0,"activeSeconds":10}]}""")] + public async Task PostSession_InvalidInput_IsRejectedAndLeavesProfileUnchanged(string scenario, string bodyTemplate) { + PageInfo page = GetLargestPage(); string userId = await CreateUserWithBaselineProfileAsync(); using HttpClient client = await CreateAuthenticatedClientAsync(userId); - using var request = CreateSessionRequest( - [ - new ReadingController.ReadingIntervalDto(new string('p', 257), 60, 100, false) - ]); + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/reading/session") + { + Content = JsonBody(bodyTemplate.Replace("{KEY}", page.Key, StringComparison.Ordinal)) + }; using HttpResponseMessage response = await client.SendAsync(request); - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); - UserReadingProfile profile = await GetProfileAsync(userId); - await Assert.That(profile.TotalWordsRead).IsEqualTo(1200L); - await Assert.That(profile.TotalActiveSeconds).IsEqualTo(360L); - await Assert.That(await GetActivityCountAsync(userId)).IsEqualTo(0); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest).Because(scenario); + await AssertProfileAsync(userId, BaselineWords, BaselineSeconds); } [Test] - public async Task PostSession_ConcurrentRequests_PreserveBothAggregateIncrements() + public async Task PostSession_ValidationFailure_ReturnsProblemDetails() { string userId = await CreateUserWithBaselineProfileAsync(); - using HttpClient firstClient = await CreateAuthenticatedClientAsync(userId); - using HttpClient secondClient = await CreateAuthenticatedClientAsync(userId); - using HttpRequestMessage firstRequest = CreateSessionRequest( - [new ReadingController.ReadingIntervalDto("first-page", 60, 100, false)]); - using HttpRequestMessage secondRequest = CreateSessionRequest( - [new ReadingController.ReadingIntervalDto("second-page", 60, 100, false)]); - - Task firstTask = firstClient.SendAsync(firstRequest); - Task secondTask = secondClient.SendAsync(secondRequest); - using HttpResponseMessage firstResponse = await firstTask; - using HttpResponseMessage secondResponse = await secondTask; - - await Assert.That(firstResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); - await Assert.That(secondResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); - UserReadingProfile profile = await GetProfileAsync(userId); - await Assert.That(profile.TotalWordsRead).IsEqualTo(1400L); - await Assert.That(profile.TotalActiveSeconds).IsEqualTo(480L); - await Assert.That(await GetActivityCountAsync(userId)).IsEqualTo(2); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + using HttpResponseMessage response = await PostSessionAsync(client, Interval("no-such-page", 10, 10)); + + JsonElement body = await response.Content.ReadFromJsonAsync(); + using (Assert.Multiple()) + { + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(response.Content.Headers.ContentType?.MediaType).IsEqualTo("application/problem+json"); + await Assert.That(body.GetProperty("status").GetInt32()).IsEqualTo(400); + await Assert.That(body.TryGetProperty("errors", out _)).IsTrue(); + } } + // ---- user isolation ---- + [Test] - public async Task PostSession_ConcurrentFirstRequests_CreateOneProfileAndPreserveBothIncrements() + public async Task PostSession_UpdatesOnlyTheAuthenticatedUsersProfile() { - string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-first-session"); - using HttpClient firstClient = await CreateAuthenticatedClientAsync(userId); - using HttpClient secondClient = await CreateAuthenticatedClientAsync(userId); - using HttpRequestMessage firstRequest = CreateSessionRequest( - [new ReadingController.ReadingIntervalDto("first-page", 60, 100, false)]); - using HttpRequestMessage secondRequest = CreateSessionRequest( - [new ReadingController.ReadingIntervalDto("second-page", 60, 100, false)]); - - Task firstTask = firstClient.SendAsync(firstRequest); - Task secondTask = secondClient.SendAsync(secondRequest); - using HttpResponseMessage firstResponse = await firstTask; - using HttpResponseMessage secondResponse = await secondTask; - - await Assert.That(firstResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); - await Assert.That(secondResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); - UserReadingProfile profile = await GetProfileAsync(userId); - await Assert.That(profile.TotalWordsRead).IsEqualTo(200L); - await Assert.That(profile.TotalActiveSeconds).IsEqualTo(120L); - await Assert.That(await GetActivityCountAsync(userId)).IsEqualTo(2); + PageInfo page = GetLargestPage(); + string callerId = await CreateUserWithBaselineProfileAsync(); + string otherId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(callerId); + + using HttpResponseMessage response = await PostSessionAsync(client, Interval(page.Key, 50, 30)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await AssertProfileAsync(callerId, BaselineWords + 50, BaselineSeconds + 30); + await AssertProfileAsync(otherId, BaselineWords, BaselineSeconds); } [Test] - public async Task GetBookStats_Returns200WithChapters() + public async Task GetProfile_ReturnsOnlyTheAuthenticatedUsersTotals() { - using HttpClient client = CreateClientWithoutAutoRedirect(); - using HttpResponseMessage response = await client.GetAsync("/api/reading/book-stats"); - await Assert.That((int)response.StatusCode).IsEqualTo(200); + string callerId = await McpTestHelper.CreateUserAsync(Factory, "reading-caller"); + await SeedProfileAsync(await McpTestHelper.CreateUserAsync(Factory, "reading-other"), 5000, 100); + using HttpClient client = await CreateAuthenticatedClientAsync(callerId); - var body = await response.Content.ReadFromJsonAsync(); - await Assert.That(body).IsNotNull(); + using HttpResponseMessage response = await client.GetAsync("/api/reading/profile"); + + JsonElement body = await response.Content.ReadFromJsonAsync(); + await Assert.That(body.GetProperty("totalWordsRead").GetInt64()).IsEqualTo(0L); + } + + // ---- reset ---- + + [Test] + public async Task Reset_RemovesOnlyTheCallersProfile() + { + string callerId = await CreateUserWithBaselineProfileAsync(); + string otherId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(callerId); + + using HttpResponseMessage response = await client.PostAsync("/api/reading/reset", null); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + bool callerHasProfile = await InServiceScopeAsync(services => + services.GetRequiredService() + .UserReadingProfiles.AnyAsync(p => p.UserId == callerId)); + await Assert.That(callerHasProfile).IsFalse(); + await AssertProfileAsync(otherId, BaselineWords, BaselineSeconds); + } + + [Test] + public async Task Reset_UserWithoutData_Returns204() + { + string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-reset-empty"); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + using HttpResponseMessage response = await client.PostAsync("/api/reading/reset", null); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); } - // ---- DTO for deserializing book-stats response ---- + // ---- concurrency ---- - private sealed class BookStatsResponse + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task PostSession_ConcurrentRequests_SumCorrectly(bool userAlreadyHasProfile) { - public int TotalWordCount { get; set; } - public IEnumerable? Chapters { get; set; } + PageInfo page = GetLargestPage(); + // Stay below the per-user write rate limit. + const int parallelRequests = 16; + const int wordsPerRequest = 40; + const int secondsPerRequest = 20; + string userId = userAlreadyHasProfile + ? await CreateUserWithBaselineProfileAsync() + : await McpTestHelper.CreateUserAsync(Factory, "reading-concurrent"); + long baselineWords = userAlreadyHasProfile ? BaselineWords : 0; + long baselineSeconds = userAlreadyHasProfile ? BaselineSeconds : 0; + + // Authenticate every client up front so the requests themselves start together. + var clients = new List(); + try + { + for (int i = 0; i < parallelRequests; i++) + { + clients.Add(await CreateAuthenticatedClientAsync(userId)); + } + + using var startBarrier = new ManualResetEventSlim(false); + Task[] requests = clients.Select(client => Task.Run(async () => + { + startBarrier.Wait(); + using HttpResponseMessage response = await PostSessionAsync( + client, Interval(page.Key, wordsPerRequest, secondsPerRequest)); + return response.StatusCode; + })).ToArray(); + + startBarrier.Set(); + HttpStatusCode[] statuses = await Task.WhenAll(requests); + + await Assert.That(statuses.All(status => status == HttpStatusCode.OK)).IsTrue(); + await AssertProfileAsync( + userId, + baselineWords + (long)parallelRequests * wordsPerRequest, + baselineSeconds + (long)parallelRequests * secondsPerRequest); + } + finally + { + foreach (HttpClient client in clients) + { + client.Dispose(); + } + } } - private sealed class ChapterInfo + // ---- book stats ---- + + [Test] + public async Task GetBookStats_IsPublicAndTotalsMatchChapters() { - public int ChapterNumber { get; set; } - public int WordCount { get; set; } + using HttpClient client = CreateClientWithoutAutoRedirect(); + + using HttpResponseMessage response = await client.GetAsync("/api/reading/book-stats"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + BookStatsResponse? body = await response.Content.ReadFromJsonAsync( + new JsonSerializerOptions(JsonSerializerDefaults.Web)); + await Assert.That(body).IsNotNull(); + using (Assert.Multiple()) + { + await Assert.That(body!.Chapters).IsNotEmpty(); + await Assert.That(body.TotalWordCount).IsGreaterThan(0); + await Assert.That(body.Chapters.Sum(c => c.WordCount)).IsEqualTo(body.TotalWordCount); + await Assert.That(body.Chapters.All(c => c.ChapterNumber > 0)).IsTrue(); + } } + // ---- helpers ---- + + private sealed record PageInfo(string Key, int WordCount); + + private static PageInfo GetLargestPage() => new("reading-test-large", PageWordCounts["reading-test-large"]); + + private static PageInfo GetSmallestPage() => new("reading-test-small", PageWordCounts["reading-test-small"]); + + private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json"); + + private static object Interval(string pageKey, int wordsRead, int activeSeconds) => + new { pageKey, wordsRead, activeSeconds, completed = false }; + + private static Task PostSessionAsync(HttpClient client, params object[] intervals) => + client.PostAsJsonAsync("/api/reading/session", new { intervals }); + private async Task CreateUserWithBaselineProfileAsync() { string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-test"); + await SeedProfileAsync(userId, BaselineWords, BaselineSeconds); + return userId; + } + + private async Task SeedProfileAsync(string userId, long words, long seconds) => await InServiceScopeAsync(async services => { - EssentialCSharpWebContext context = - services.GetRequiredService(); + EssentialCSharpWebContext context = services.GetRequiredService(); context.UserReadingProfiles.Add(new UserReadingProfile { UserId = userId, - TotalWordsRead = 1200, - TotalActiveSeconds = 360 + TotalWordsRead = words, + TotalActiveSeconds = seconds, + UpdatedAtUtc = DateTime.UtcNow }); await context.SaveChangesAsync(); }); - return userId; - } - private async Task CreateAuthenticatedClientAsync(string userId) + private async Task GetProfileAsync(string userId) => + await InServiceScopeAsync(services => + services.GetRequiredService() + .UserReadingProfiles.AsNoTracking().SingleAsync(p => p.UserId == userId)); + + private async Task AssertProfileAsync(string userId, long expectedWords, long expectedSeconds) { - HttpClient client = McpTestHelper.CreateClient(Factory); - (string cookieName, string cookieValue) = - await McpTestHelper.CreateIdentityApplicationCookieAsync(Factory, userId); - client.DefaultRequestHeaders.Add("Cookie", $"{cookieName}={cookieValue}"); - - using HttpResponseMessage pageResponse = await client.GetAsync("/about"); - string pageContent = await pageResponse.Content.ReadAsStringAsync(); - Match requestToken = Regex.Match( - pageContent, - ">() - .Value.Cookie.Name - ?? throw new InvalidOperationException("The antiforgery cookie name is not configured."); - string antiforgeryCookie = pageResponse.Headers.GetValues("Set-Cookie") - .Select(value => value.Split(';', 2)[0]) - .Single(value => value.StartsWith( - $"{antiforgeryCookieName}=", - StringComparison.Ordinal)); - - client.DefaultRequestHeaders.Remove("Cookie"); - client.DefaultRequestHeaders.Add( - "Cookie", - $"{cookieName}={cookieValue}; {antiforgeryCookie}"); - client.DefaultRequestHeaders.Add( - "RequestVerificationToken", - requestToken.Groups[1].Value); - - return client; } - private static HttpRequestMessage CreateSessionRequest( - IEnumerable intervals) + /// + /// Creates a client signed in as . The antiforgery cookie and request + /// token are minted directly from for the same principal the app would + /// build at sign-in, so no page needs to be scraped. + /// + private async Task CreateAuthenticatedClientAsync(string userId, bool includeAntiforgery = true) { - return new HttpRequestMessage(HttpMethod.Post, "/api/reading/session") - { - Content = JsonContent.Create(intervals) - }; - } + HttpClient client = McpTestHelper.CreateClient(Factory); + (string cookieName, string cookieValue) = + await McpTestHelper.CreateIdentityApplicationCookieAsync(Factory, userId); + string cookieHeader = $"{cookieName}={cookieValue}"; - private async Task GetProfileAsync(string userId) - { - using IServiceScope scope = - Factory.Services.GetRequiredService().CreateScope(); - return await scope.ServiceProvider.GetRequiredService() - .UserReadingProfiles.SingleAsync(profile => profile.UserId == userId); - } + if (includeAntiforgery) + { + (string antiforgeryCookie, string requestToken, string headerName) = await InServiceScopeAsync(async services => + { + EssentialCSharpWebUser user = await services.GetRequiredService>() + .FindByIdAsync(userId) + ?? throw new InvalidOperationException($"User {userId} does not exist."); + var context = new DefaultHttpContext + { + RequestServices = services, + User = await services.GetRequiredService>() + .CreateAsync(user) + }; + + AntiforgeryTokenSet tokens = services.GetRequiredService().GetAndStoreTokens(context); + string antiforgeryCookieName = services.GetRequiredService>().Value.Cookie.Name + ?? throw new InvalidOperationException("The antiforgery cookie name is not configured."); + string cookie = context.Response.Headers.SetCookie + .Select(value => value?.Split(';', 2)[0]) + .OfType() + .Single(value => value.StartsWith(antiforgeryCookieName + "=", StringComparison.Ordinal)); + return ( + cookie, + tokens.RequestToken ?? throw new InvalidOperationException("No antiforgery request token."), + tokens.HeaderName ?? "RequestVerificationToken"); + }); - private async Task GetActivityCountAsync(string userId) - { - using IServiceScope scope = - Factory.Services.GetRequiredService().CreateScope(); - return await scope.ServiceProvider.GetRequiredService() - .ReadingActivities.CountAsync(activity => activity.UserId == userId); - } + cookieHeader += $"; {antiforgeryCookie}"; + client.DefaultRequestHeaders.Add(headerName, requestToken); + } - private async Task GetSingleActivityAsync(string userId) - { - using IServiceScope scope = - Factory.Services.GetRequiredService().CreateScope(); - return await scope.ServiceProvider.GetRequiredService() - .ReadingActivities.SingleAsync(activity => activity.UserId == userId); + client.DefaultRequestHeaders.Add("Cookie", cookieHeader); + return client; } } diff --git a/EssentialCSharp.Web.Tests/ReadingSpeedPolicyTests.cs b/EssentialCSharp.Web.Tests/ReadingSpeedPolicyTests.cs new file mode 100644 index 00000000..3fcc853f --- /dev/null +++ b/EssentialCSharp.Web.Tests/ReadingSpeedPolicyTests.cs @@ -0,0 +1,110 @@ +using EssentialCSharp.Web.Services; + +namespace EssentialCSharp.Web.Tests; + +public class ReadingSpeedPolicyTests +{ + private static readonly ReadingOptions Options = new(); + + private const double ReferenceWpm = 200; + + /// Seconds needed to read at exactly . + private static double SecondsAt(int words, double wpm) => words / wpm * 60.0; + + [Test] + public async Task Evaluate_IntervalAtHardCutoff_IsCreditedInFull() + { + const int seconds = 20; + int words = (int)(Options.HardCutoffWpm * seconds / 60.0); + + ReadingCredit? credit = ReadingSpeedPolicy.Evaluate(words, seconds, ReferenceWpm, Options); + + await Assert.That(credit).IsEqualTo(new ReadingCredit(words, seconds)); + } + + [Test] + public async Task Evaluate_IntervalJustAboveHardCutoff_IsIgnored() + { + const int seconds = 20; + int words = (int)(Options.HardCutoffWpm * seconds / 60.0) + 1; + + ReadingCredit? credit = ReadingSpeedPolicy.Evaluate(words, seconds, ReferenceWpm, Options); + + await Assert.That(credit).IsNull(); + } + + [Test] + public async Task Evaluate_IntervalJustAboveSlowThreshold_IsNotClamped() + { + double thresholdWpm = Options.SlowOutlierFactor * ReferenceWpm; + const int words = 100; + int seconds = (int)Math.Floor(SecondsAt(words, thresholdWpm)); + + ReadingCredit? credit = ReadingSpeedPolicy.Evaluate(words, seconds, ReferenceWpm, Options); + + await Assert.That(credit).IsEqualTo(new ReadingCredit(words, seconds)); + } + + [Test] + public async Task Evaluate_VerySlowInterval_CountsForLessThanItsWallClockTime() + { + const int words = 100; + int seconds = (int)(SecondsAt(words, ReferenceWpm) * 20); + + ReadingCredit? credit = ReadingSpeedPolicy.Evaluate(words, seconds, ReferenceWpm, Options); + + await Assert.That(credit).IsNotNull(); + using (Assert.Multiple()) + { + await Assert.That(credit!.Value.Words).IsEqualTo(words); + await Assert.That(credit.Value.Seconds).IsLessThan(seconds); + // Credited as if read at the slow-outlier floor speed. + double creditedWpm = credit.Value.Words / (credit.Value.Seconds / 60.0); + await Assert.That(creditedWpm).IsEqualTo(Options.SlowOutlierFactor * ReferenceWpm).Within(1.0); + } + } + + [Test] + public async Task Evaluate_SlowIntervalWithNoReference_IsCreditedInFull() + { + ReadingCredit? credit = ReadingSpeedPolicy.Evaluate(10, 600, 0, Options); + + await Assert.That(credit).IsEqualTo(new ReadingCredit(10, 600)); + } + + [Test] + public async Task Evaluate_TinySlowInterval_IsCreditedWithAtLeastOneSecond() + { + ReadingCredit? credit = ReadingSpeedPolicy.Evaluate(1, 3600, 100_000, Options); + + await Assert.That(credit).IsNotNull(); + await Assert.That(credit!.Value.Seconds).IsGreaterThanOrEqualTo(1); + } + + [Test] + [Arguments(0, 10)] + [Arguments(10, 0)] + [Arguments(-5, 10)] + [Arguments(10, -5)] + public async Task Evaluate_NonPositiveInput_IsIgnored(int words, int seconds) + { + ReadingCredit? credit = ReadingSpeedPolicy.Evaluate(words, seconds, ReferenceWpm, Options); + + await Assert.That(credit).IsNull(); + } + + [Test] + public async Task Evaluate_RespectsConfiguredCutoff() + { + var strict = new ReadingOptions { HardCutoffWpm = 100 }; + + ReadingCredit? atLimit = ReadingSpeedPolicy.Evaluate(100, 60, 0, strict); + ReadingCredit? overLimit = ReadingSpeedPolicy.Evaluate(101, 60, 0, strict); + + using (Assert.Multiple()) + { + await Assert.That(atLimit).IsNotNull(); + await Assert.That(overLimit).IsNull(); + } + } +} From 39bf2a19745f4cd3199b0316c96dbbb89833d22b Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Thu, 5 Nov 2026 21:16:32 -0800 Subject: [PATCH 13/38] fix: reconcile reading tracker client/server contract and scope the content marker --- EssentialCSharp.Web.Tests/McpApiTokenServiceTests.cs | 6 +++--- EssentialCSharp.Web.Tests/PwnedPasswordValidatorTests.cs | 2 +- EssentialCSharp.Web/Program.cs | 8 ++++---- EssentialCSharp.Web/Views/Home/Index.cshtml | 4 +++- EssentialCSharp.Web/src/services/readingApi.js | 2 +- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/EssentialCSharp.Web.Tests/McpApiTokenServiceTests.cs b/EssentialCSharp.Web.Tests/McpApiTokenServiceTests.cs index 302a8685..0eb417bd 100644 --- a/EssentialCSharp.Web.Tests/McpApiTokenServiceTests.cs +++ b/EssentialCSharp.Web.Tests/McpApiTokenServiceTests.cs @@ -45,7 +45,7 @@ public async Task CreateTokenAsync_WithoutExpiry_UsesSixMonthDefault() (_, var entity) = await tokenService.CreateTokenAsync(userId, "default-expiry"); await Assert.That(entity.ExpiresAt).IsNotNull(); - await Assert.That(entity.ExpiresAt.Value) + await Assert.That(entity.ExpiresAt!.Value) .IsEqualTo(McpApiTokenService.GetDefaultExpirationUtc(entity.CreatedAt)); } @@ -58,7 +58,7 @@ public async Task CreateTokenAsync_WithExpiryWithinSixMonths_UsesRequestedExpiry (_, var entity) = await tokenService.CreateTokenAsync(userId, "custom-expiry", requestedExpiry); await Assert.That(entity.ExpiresAt).IsNotNull(); - await Assert.That(entity.ExpiresAt.Value).IsEqualTo(requestedExpiry); + await Assert.That(entity.ExpiresAt!.Value).IsEqualTo(requestedExpiry); } [Test] @@ -83,7 +83,7 @@ public async Task CreateTokenAsync_WithExplicitCreatedAt_UsesReferenceTimeForDef await Assert.That(entity.CreatedAt).IsEqualTo(createdAtUtc); await Assert.That(entity.ExpiresAt).IsNotNull(); - await Assert.That(entity.ExpiresAt.Value) + await Assert.That(entity.ExpiresAt!.Value) .IsEqualTo(McpApiTokenService.GetDefaultExpirationUtc(createdAtUtc)); } diff --git a/EssentialCSharp.Web.Tests/PwnedPasswordValidatorTests.cs b/EssentialCSharp.Web.Tests/PwnedPasswordValidatorTests.cs index d56c1c3d..891dbe53 100644 --- a/EssentialCSharp.Web.Tests/PwnedPasswordValidatorTests.cs +++ b/EssentialCSharp.Web.Tests/PwnedPasswordValidatorTests.cs @@ -107,7 +107,7 @@ public async Task ValidateAsync_SendsCorrectKAnonymityRequest() await validator.ValidateAsync(manager, new IdentityUser("testuser"), "password"); await Assert.That(capturedRequest).IsNotNull(); - await Assert.That(capturedRequest.RequestUri!.PathAndQuery).IsEqualTo("/range/5BAA6"); + await Assert.That(capturedRequest!.RequestUri!.PathAndQuery).IsEqualTo("/range/5BAA6"); await Assert.That(capturedRequest.Headers.Contains("Add-Padding")).IsTrue(); await Assert.That(capturedRequest.Headers.GetValues("Add-Padding").Single()).IsEqualTo("true"); } diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 2ecfc04d..23b9e182 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -272,7 +272,10 @@ private static void Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + // One instance serves both the query interface and the startup hook that builds the cache. + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.Configure(builder.Configuration.GetSection(ReadingOptions.SectionName)); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -467,9 +470,6 @@ await context.HttpContext.Response.WriteAsync( WebApplication app = builder.Build(); - // Build the page word-count cache during startup instead of on the first content request. - _ = app.Services.GetRequiredService(); - if (profilerSkippedUnsupportedPlatform) LogSkippingUnsupportedAzureMonitorProfiler( app.Services.GetRequiredService>(), diff --git a/EssentialCSharp.Web/Views/Home/Index.cshtml b/EssentialCSharp.Web/Views/Home/Index.cshtml index af43c343..0c03ced6 100644 --- a/EssentialCSharp.Web/Views/Home/Index.cshtml +++ b/EssentialCSharp.Web/Views/Home/Index.cshtml @@ -7,4 +7,6 @@

@ViewBag.PageTitle

} -@Html.Raw(ViewBag.Contents) +
+ @Html.Raw(ViewBag.Contents) +
diff --git a/EssentialCSharp.Web/src/services/readingApi.js b/EssentialCSharp.Web/src/services/readingApi.js index 142ee5f1..3fa46973 100644 --- a/EssentialCSharp.Web/src/services/readingApi.js +++ b/EssentialCSharp.Web/src/services/readingApi.js @@ -55,7 +55,7 @@ export async function postSession(intervals, { keepalive = false } = {}) { RequestVerificationToken: getCsrfToken() }, // The server currently binds the body to a bare array of intervals. - body: JSON.stringify(intervals), + body: JSON.stringify({ intervals }), keepalive }); return { status: statusFor(res) }; From bea88a796c3ed827f99ddde30257719c7a059fcc Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Thu, 5 Nov 2026 21:17:35 -0800 Subject: [PATCH 14/38] test: align reading test fake with refactored IWordCountService --- EssentialCSharp.Web.Tests/ReadingControllerTests.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/EssentialCSharp.Web.Tests/ReadingControllerTests.cs b/EssentialCSharp.Web.Tests/ReadingControllerTests.cs index c9e26189..5e25a5d1 100644 --- a/EssentialCSharp.Web.Tests/ReadingControllerTests.cs +++ b/EssentialCSharp.Web.Tests/ReadingControllerTests.cs @@ -46,11 +46,10 @@ protected override void ConfigureTestServices(IServiceCollection services) private sealed class FakeWordCountService : IWordCountService { public int GetPageWordCount(string pageKey) => PageWordCounts.GetValueOrDefault(pageKey); - public int GetChapterWordCount(int chapterNumber) => - ChapterWordCounts.Where(c => c.ChapterNumber == chapterNumber).Sum(c => c.WordCount); public int GetBookWordCount() => ChapterWordCounts.Sum(c => c.WordCount); - public int GetWordsBeforePage(string pageKey) => 0; - public int GetChapterStartWords(string pageKey) => 0; + public bool IsReady => true; + public PageWordStats? GetPageStats(string pageKey) => + PageWordCounts.TryGetValue(pageKey, out int words) ? new PageWordStats(words, words, 0, 0, GetBookWordCount()) : null; public IReadOnlyList GetChapterWordCounts() => ChapterWordCounts; } From 25f7da3a89c4e16ceb41923b177024f28e204f86 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Thu, 5 Nov 2026 22:07:33 -0800 Subject: [PATCH 15/38] fix: validated single-source reading options, provider-agnostic upsert, personal data export, regenerated migration - ReadingOptions: DefaultWpm/MaxCreditWpm/IdleThresholdSeconds/PeriodicFlushActiveSeconds/MaxActiveSecondsPerInterval with validation (ValidateOnStart + cross-field validator) - drop unused Completed from the interval DTO; per-interval active-seconds cap from options - replace MERGE/ON CONFLICT with ExecuteUpdate + insert-on-miss + duplicate-key retry - READING_CONTEXT carries wpm and client constants - include UserReadingProfile in personal data download - restore Program.cs CRLF and empty OnModelCreating; regenerate migration --- .../Data/EssentialCSharpWebContext.cs | 5 + .../Manage/DownloadPersonalData.cshtml.cs | 14 + .../Controllers/ReadingController.cs | 9 + .../Controllers/ReadingIntervalRequest.cs | 11 +- ...6060723_AddUserReadingProfile.Designer.cs} | 2 +- ...> 20261106060723_AddUserReadingProfile.cs} | 0 EssentialCSharp.Web/Program.cs | 1472 +++++++++-------- .../Services/ReadingOptions.cs | 59 +- .../Services/ReadingProfileService.cs | 68 +- .../Services/ReadingSpeedPolicy.cs | 5 +- .../ReadingContextViewComponent.cs | 60 +- 11 files changed, 926 insertions(+), 779 deletions(-) rename EssentialCSharp.Web/Migrations/{20261106050505_AddUserReadingProfile.Designer.cs => 20261106060723_AddUserReadingProfile.Designer.cs} (99%) rename EssentialCSharp.Web/Migrations/{20261106050505_AddUserReadingProfile.cs => 20261106060723_AddUserReadingProfile.cs} (100%) diff --git a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs index 627be8cb..a56d4e9d 100644 --- a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs +++ b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs @@ -12,4 +12,9 @@ public class EssentialCSharpWebContext(DbContextOptions DataProtectionKeys { get; set; } = null!; public DbSet McpApiTokens { get; set; } = null!; public DbSet UserReadingProfiles { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + } } diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs index 671957b1..4debe28f 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs @@ -1,13 +1,17 @@ using System.Text.Json; using EssentialCSharp.Web.Areas.Identity.Data; +using EssentialCSharp.Web.Data; +using EssentialCSharp.Web.Models; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; namespace EssentialCSharp.Web.Areas.Identity.Pages.Account.Manage; public partial class DownloadPersonalDataModel( UserManager userManager, + EssentialCSharpWebContext context, ILogger logger) : PageModel { public IActionResult OnGet() @@ -45,6 +49,16 @@ public async Task OnPostAsync() personalData.Add($"Authenticator Key", authenticatorKey); } + UserReadingProfile? readingProfile = await context.UserReadingProfiles + .AsNoTracking() + .FirstOrDefaultAsync(p => p.UserId == user.Id); + if (readingProfile is not null) + { + personalData.Add("Reading total words read", readingProfile.TotalWordsRead.ToString(System.Globalization.CultureInfo.InvariantCulture)); + personalData.Add("Reading total active seconds", readingProfile.TotalActiveSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)); + personalData.Add("Reading profile last updated (UTC)", readingProfile.UpdatedAtUtc.ToString("O", System.Globalization.CultureInfo.InvariantCulture)); + } + Response.Headers.Append("Content-Disposition", "attachment; filename=PersonalData.json"); return new FileContentResult(JsonSerializer.SerializeToUtf8Bytes(personalData), "application/json"); } diff --git a/EssentialCSharp.Web/Controllers/ReadingController.cs b/EssentialCSharp.Web/Controllers/ReadingController.cs index 9ed90226..ac4ef85f 100644 --- a/EssentialCSharp.Web/Controllers/ReadingController.cs +++ b/EssentialCSharp.Web/Controllers/ReadingController.cs @@ -79,6 +79,15 @@ public async Task> PostSession( return ValidationProblem(ModelState); } + int maxActiveSeconds = options.Value.MaxActiveSecondsPerInterval; + if (request.Intervals.Any(i => i.ActiveSeconds > maxActiveSeconds)) + { + ModelState.AddModelError( + nameof(ReadingIntervalRequest.ActiveSeconds), + $"An interval may contain at most {maxActiveSeconds} active seconds."); + return ValidationProblem(ModelState); + } + ReadingInterval[] intervals = request.Intervals .Select(i => new ReadingInterval(i.PageKey, i.WordsRead, i.ActiveSeconds)) .ToArray(); diff --git a/EssentialCSharp.Web/Controllers/ReadingIntervalRequest.cs b/EssentialCSharp.Web/Controllers/ReadingIntervalRequest.cs index b02c7233..9ea2fa9c 100644 --- a/EssentialCSharp.Web/Controllers/ReadingIntervalRequest.cs +++ b/EssentialCSharp.Web/Controllers/ReadingIntervalRequest.cs @@ -5,7 +5,6 @@ namespace EssentialCSharp.Web.Controllers; public sealed class ReadingIntervalRequest { public const int PageKeyMaxLength = 256; - public const int MaxActiveSeconds = 24 * 60 * 60; public const int MaxWordsRead = 100_000; /// The page's site-mapping key. @@ -13,14 +12,14 @@ public sealed class ReadingIntervalRequest [StringLength(PageKeyMaxLength, MinimumLength = 1)] public string PageKey { get; set; } = string.Empty; - /// Active (non-idle, visible) seconds spent on the page. - [Range(1, MaxActiveSeconds)] + /// + /// Active (non-idle, visible) seconds spent on the page. The upper bound is + /// ReadingOptions.MaxActiveSecondsPerInterval, enforced by the controller. + /// + [Range(1, int.MaxValue)] public int ActiveSeconds { get; set; } /// Prose words read during the interval. [Range(1, MaxWordsRead)] public int WordsRead { get; set; } - - /// Whether the reader reached the end of the page. Currently informational only. - public bool Completed { get; set; } } diff --git a/EssentialCSharp.Web/Migrations/20261106050505_AddUserReadingProfile.Designer.cs b/EssentialCSharp.Web/Migrations/20261106060723_AddUserReadingProfile.Designer.cs similarity index 99% rename from EssentialCSharp.Web/Migrations/20261106050505_AddUserReadingProfile.Designer.cs rename to EssentialCSharp.Web/Migrations/20261106060723_AddUserReadingProfile.Designer.cs index 40cee064..71c36124 100644 --- a/EssentialCSharp.Web/Migrations/20261106050505_AddUserReadingProfile.Designer.cs +++ b/EssentialCSharp.Web/Migrations/20261106060723_AddUserReadingProfile.Designer.cs @@ -12,7 +12,7 @@ namespace EssentialCSharp.Web.Migrations { [DbContext(typeof(EssentialCSharpWebContext))] - [Migration("20261106050505_AddUserReadingProfile")] + [Migration("20261106060723_AddUserReadingProfile")] partial class AddUserReadingProfile { /// diff --git a/EssentialCSharp.Web/Migrations/20261106050505_AddUserReadingProfile.cs b/EssentialCSharp.Web/Migrations/20261106060723_AddUserReadingProfile.cs similarity index 100% rename from EssentialCSharp.Web/Migrations/20261106050505_AddUserReadingProfile.cs rename to EssentialCSharp.Web/Migrations/20261106060723_AddUserReadingProfile.cs diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 23b9e182..f236edba 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -1,734 +1,738 @@ -using System.Security.Claims; -using System.Runtime.InteropServices; -using System.Threading.RateLimiting; -using ModelContextProtocol.Protocol; -using EssentialCSharp.Chat.Common.Extensions; -using EssentialCSharp.Web.Areas.Identity.Data; -using EssentialCSharp.Web.Areas.Identity.Services.PasswordValidators; -using EssentialCSharp.Web.Auth; -using EssentialCSharp.Web.Controllers; -using EssentialCSharp.Web.Data; -using EssentialCSharp.Web.Extensions; -using EssentialCSharp.Web.Helpers; -using EssentialCSharp.Web.Middleware; -using EssentialCSharp.Web.Services; -using EssentialCSharp.Web.Services.Referrals; -using EssentialCSharp.Web.Tools; -using Mailjet.Client; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Identity.UI.Services; -using Microsoft.AspNetCore.RateLimiting; -using Azure.Monitor.OpenTelemetry.AspNetCore; -using Azure.Monitor.OpenTelemetry.Profiler; -using Microsoft.AspNetCore.DataProtection; -using Microsoft.AspNetCore.Diagnostics; -using Microsoft.AspNetCore.Diagnostics.HealthChecks; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using OpenTelemetry; -using OpenTelemetry.Instrumentation.AspNetCore; -using OpenTelemetry.Metrics; -using OpenTelemetry.Trace; - -namespace EssentialCSharp.Web; - -public partial class Program -{ - private static void Main(string[] args) - { - WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - - // Health checks (liveness/readiness probes for ACA and standalone hosting) - builder.Services.AddHealthChecks() - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); - - // Prefer Aspire's OTLP endpoint when present; otherwise export to Azure Monitor. - // This keeps local traces in the dashboard even when a developer has an App Insights connection string configured. - string? appInsightsConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]; - bool useOtlp = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); - bool useAzureMonitor = !useOtlp && !string.IsNullOrWhiteSpace(appInsightsConnectionString); - bool profilerSupportedPlatform = OperatingSystem.IsWindows() || OperatingSystem.IsLinux(); - bool profilerSkippedUnsupportedPlatform = false; - - builder.Logging.AddOpenTelemetry(logging => - { - logging.IncludeFormattedMessage = true; - logging.IncludeScopes = true; - }); - - // Health probe paths excluded from tracing unconditionally — applies to both - // manual instrumentation and Azure Monitor's auto-instrumentation. - builder.Services.Configure(options => - { - options.Filter = ctx => - !ctx.Request.Path.StartsWithSegments("/health") - && !ctx.Request.Path.StartsWithSegments("/alive"); - // EnrichWithHttpResponse fires after the authentication middleware has run, - // so HttpContext.User is populated and IsAuthenticated is reliable. - options.EnrichWithHttpResponse = (activity, response) => - { - var user = response.HttpContext.User; - if (user?.Identity?.IsAuthenticated != true) - { - return; - } - - string? userId = user.FindFirstValue(ClaimTypes.NameIdentifier); - if (!string.IsNullOrWhiteSpace(userId)) - { - activity.SetTag("enduser.id", userId); - } - }; - }); - - var otel = builder.Services.AddOpenTelemetry() - .WithMetrics(metrics => - { - // Azure Monitor auto-instruments ASP.NET Core + HttpClient metrics; only add - // them manually when using OTLP so we don't register duplicate meter listeners. - if (!useAzureMonitor) - { - metrics.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation(); - } - // Runtime metrics are not included in the Azure Monitor distro. - metrics.AddRuntimeInstrumentation(); - }) - .WithTracing(tracing => - { - tracing.AddSource(builder.Environment.ApplicationName); - // Azure Monitor distro auto-instruments tracing; add manually only for OTLP path. - if (!useAzureMonitor) - { - tracing.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddSqlClientInstrumentation(); - } - }); - - if (useAzureMonitor) - { - // Azure Monitor export is supported cross-platform, but the profiler currently only - // supports Windows and Linux. - var azureMonitor = otel.UseAzureMonitor(); - if (profilerSupportedPlatform) - azureMonitor.AddAzureMonitorProfiler(); - else - profilerSkippedUnsupportedPlatform = true; - } - else if (useOtlp) - otel.UseOtlpExporter(); - - // HttpClient defaults — standard retry/circuit breaker for all named clients. - builder.Services.ConfigureHttpClientDefaults(http => http.AddStandardResilienceHandler()); - - builder.Services.AddHttpClient("HaveIBeenPwned", c => - { - c.BaseAddress = new Uri("https://api.pwnedpasswords.com/"); - c.DefaultRequestHeaders.UserAgent.ParseAdd("EssentialCSharp.Web/1.0"); - // Short timeout: this check is advisory/fail-open, so cap latency impact on auth flows. - c.Timeout = TimeSpan.FromSeconds(3); - }); - - builder.Services.AddTrustedForwardedHeaders(builder.Configuration, builder.Environment); - builder.Services.AddHttpsRedirection(options => - { - options.HttpsPort = 443; - }); - - ConfigurationManager configuration = builder.Configuration; - string connectionString = builder.Configuration.GetConnectionString("EssentialCSharpWebContextConnection") ?? throw new InvalidOperationException("Connection string 'EssentialCSharpWebContextConnection' not found."); - - builder.Services.AddDbContext(options => options.UseSqlServer(connectionString, sql => sql.EnableRetryOnFailure(5))); - - if (builder.Environment.IsDevelopment()) - { - // Must run before DataProtectionHostedService, which reads DataProtectionKeys. - builder.Services.AddHostedService(); - } - - // Data Protection — persist keys to SQL Server so they survive container restarts. - // SetApplicationName ensures the discriminator is stable across container hostname changes. - var dpBuilder = builder.Services.AddDataProtection() - .SetApplicationName("EssentialCSharpWeb") - .PersistKeysToDbContext(); - var keyVaultKeyUri = builder.Configuration["DataProtection:AzureKeyVaultKeyUri"]; - if (!string.IsNullOrEmpty(keyVaultKeyUri)) - { - dpBuilder.ProtectKeysWithAzureKeyVault(new Uri(keyVaultKeyUri), new Azure.Identity.DefaultAzureCredential()); - } - else if (!builder.Environment.IsDevelopment()) - { - throw new InvalidOperationException( - "DataProtection:AzureKeyVaultKeyUri is required in non-Development environments. " + - "Set the DataProtection__AzureKeyVaultKeyUri environment variable to the Key Vault key URI."); - } - - builder.Services.AddDefaultIdentity(options => - { - // Password settings - options.User.RequireUniqueEmail = true; - options.Password.RequiredLength = PasswordRequirementOptions.PasswordMinimumLength; - options.Password.RequireDigit = false; - options.Password.RequireNonAlphanumeric = false; - options.Password.RequireUppercase = false; - options.Password.RequireLowercase = false; - options.Password.RequiredUniqueChars = 1; - - options.SignIn.RequireConfirmedEmail = true; - options.SignIn.RequireConfirmedAccount = true; - - options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(30); - options.Lockout.MaxFailedAccessAttempts = 3; - - //TODO: Implement IProtectedUserStore - //options.Stores.ProtectPersonalData = true; - }) - .AddEntityFrameworkStores() - .AddPasswordValidator>() - .AddPasswordValidator>() - .AddPasswordValidator>(); - - builder.Configuration - .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) - .AddUserSecrets() - .AddEnvironmentVariables(); - - builder.Services.ConfigureApplicationCookie(options => - { - options.Cookie.HttpOnly = true; - options.ExpireTimeSpan = TimeSpan.FromMinutes(60); - options.SlidingExpiration = true; - // API endpoints must return 401/403 instead of redirecting to the login page. - // Cookie auth's default behavior (302 redirect) causes fetch() to follow the - // redirect, eventually hitting the fallback controller and returning a 404. - options.Events.OnRedirectToLogin = context => - { - if (context.Request.Path.StartsWithSegments("/api") - || context.Request.Path.StartsWithSegments("/mcp")) - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - else - context.Response.Redirect(context.RedirectUri); - return Task.CompletedTask; - }; - options.Events.OnRedirectToAccessDenied = context => - { - if (context.Request.Path.StartsWithSegments("/api") - || context.Request.Path.StartsWithSegments("/mcp")) - context.Response.StatusCode = StatusCodes.Status403Forbidden; - else - context.Response.Redirect(context.RedirectUri); - return Task.CompletedTask; - }; - }); - - builder.Services.Configure(option => - { - // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 - // Minimum recommended is currently 210,000 iterations for pdkdf2-sha512 as of October 27, 2023 - option.IterationCount = 500000; - }); - builder.Services.AddScoped>(provider => - { - if (!provider.GetRequiredService>().SupportsUserEmail) - { - throw new NotSupportedException("The default UI requires a user store with email support."); - } - return (IUserEmailStore)provider.GetRequiredService>(); - }); - - builder.Services.AddScoped>(provider => - { - if (provider.GetRequiredService>() is IUserPasswordStore userPasswordStore) - { - return userPasswordStore; - } - throw new NotSupportedException("The default UI requires a user store with password support."); - }); - - builder.Services.AddControllersWithViews(options => - { - options.Filters.Add(new Microsoft.AspNetCore.Mvc.AutoValidateAntiforgeryTokenAttribute()); - }); - - if (!builder.Environment.IsDevelopment()) - { - builder.Services.AddTransient(); - } - builder.Services.Configure(builder.Configuration.GetSection(AuthMessageSenderOptions.AuthMessageSender)); - builder.Services.Configure(builder.Configuration.GetSection(SiteSettings.SectionName)); - - // Add services to the container. - builder.Services.AddRazorPages(); - builder.Services.AddOutputCache(); - builder.Services.AddCaptchaService(builder.Configuration.GetSection(CaptchaOptions.CaptchaSender)); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - // One instance serves both the query interface and the startup hook that builds the cache. - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - builder.Services.AddHostedService(sp => sp.GetRequiredService()); - builder.Services.Configure(builder.Configuration.GetSection(ReadingOptions.SectionName)); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - - // Add AI Chat services using configuration-driven backend selection. - builder.Services.AddConfiguredChatServices(configuration); - - // MCP server — always enabled, authenticated via opaque DB-backed tokens. - builder.Services.AddScoped(); - - builder.Services.AddMemoryCache(); - builder.Services.AddSingleton(_ => new ResponseIdValidationService()); - - builder.Services.AddAuthentication() - .AddScheme( - McpBearerAuthentication.Scheme, _ => { }); - - builder.Services.AddAuthorization(options => - options.AddPolicy("McpPolicy", policy => - policy.AddAuthenticationSchemes(McpBearerAuthentication.Scheme) - .RequireAuthenticatedUser())); - - builder.Services.AddCors(options => - options.AddPolicy("McpInspectorCors", policy => - policy.SetIsOriginAllowed(origin => - Uri.TryCreate(origin, UriKind.Absolute, out Uri? originUri) - && originUri.IsLoopback - && (originUri.Scheme == Uri.UriSchemeHttp || originUri.Scheme == Uri.UriSchemeHttps)) - .AllowAnyHeader() - .AllowAnyMethod() - .WithExposedHeaders("Mcp-Session-Id"))); - - builder.Services.AddSingleton(); - - builder.Services.AddMcpServer() - .WithHttpTransport(options => options.Stateless = true) - .WithTools() - .WithTools() - .WithTools() - .WithTools(); - - // Add Rate Limiting for API endpoints - builder.Services.AddRateLimiter(options => - { - // Global rate limiter for site requests by authenticated user ID or anonymous IP. - // MCP transport requests use a dedicated named policy attached to MapMcp("/mcp"). - options.GlobalLimiter = PartitionedRateLimiter.Create(httpContext => - { - if (httpContext.Request.Path.StartsWithSegments("/.well-known")) - return RateLimitPartition.GetNoLimiter("well-known"); - - if (IsMcpTransportRequest(httpContext.Request)) - return RateLimitPartition.GetNoLimiter("mcp-transport"); - - // Reading API endpoints are limited by their own named policies. - if (httpContext.Request.Path.StartsWithSegments("/api/reading")) - return RateLimitPartition.GetNoLimiter("reading-api"); - - var partitionKey = httpContext.User.Identity?.IsAuthenticated == true - ? httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown-user" - : httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"; - - return RateLimitPartition.GetFixedWindowLimiter( - partitionKey: partitionKey, - factory: _ => new FixedWindowRateLimiterOptions - { - PermitLimit = 30, // requests per window - Window = TimeSpan.FromMinutes(1), // minute window - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - QueueLimit = 0 // No queuing - immediate rejection for better UX - }); - }); - - options.AddPolicy("ChatEndpoint", httpContext => - { - // Partitioned per-user (when authenticated) or per-IP (anonymous) - var partitionKey = httpContext.User.Identity?.IsAuthenticated == true - ? $"chat-user:{httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown-user"}" - : $"chat-ip:{httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"}"; - - return RateLimitPartition.GetFixedWindowLimiter( - partitionKey: partitionKey, - factory: _ => new FixedWindowRateLimiterOptions - { - PermitLimit = 15, - Window = TimeSpan.FromMinutes(1), - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - QueueLimit = 0 - }); - }); - - // Reading tracker: per-user limit for authenticated endpoints, looser per-IP limit for anonymous book-stats. - options.AddPolicy(ReadingController.WriteRateLimitPolicy, httpContext => - RateLimitPartition.GetFixedWindowLimiter( - partitionKey: $"reading-user:{httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"}", - factory: _ => new FixedWindowRateLimiterOptions - { - PermitLimit = 20, - Window = TimeSpan.FromMinutes(1), - QueueLimit = 0 - })); - options.AddPolicy(ReadingController.PublicRateLimitPolicy, httpContext => - RateLimitPartition.GetFixedWindowLimiter( - partitionKey: $"reading-ip:{httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"}", - factory: _ => new FixedWindowRateLimiterOptions - { - PermitLimit = 60, - Window = TimeSpan.FromMinutes(1), - QueueLimit = 0 - })); - - // Combined per-minute burst (10/min) + per-hour cap (150/hr) for book content pages. - // A scraper cycling through the full ~400-page book needs 2+ hours at minimum. - // See Services/ContentRateLimiterPolicy.cs for implementation. - options.AddPolicy("content", new ContentRateLimiterPolicy()); - options.AddPolicy(McpRateLimiterPolicy.PolicyName, new McpRateLimiterPolicy()); - - // Custom response when rate limit is exceeded - options.OnRejected = async (context, cancellationToken) => - { - context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; - int? retryAfterSeconds = RateLimitingResponseHelpers.ApplyRetryAfterHeader( - context.HttpContext.Response, - context.Lease); - var logger = context.HttpContext.RequestServices.GetRequiredService>(); - - if (context.HttpContext.Request.Path.StartsWithSegments("/api/chat")) - { - // Custom rejection handling logic - context.HttpContext.Response.ContentType = "application/json"; - - Dictionary errorResponse = new() - { - ["error"] = "Rate limit exceeded. Please wait before sending another message.", - ["statusCode"] = 429 - }; - if (retryAfterSeconds is int retryAfter) - errorResponse["retryAfter"] = retryAfter; - - await context.HttpContext.Response.WriteAsync( - System.Text.Json.JsonSerializer.Serialize(errorResponse), - cancellationToken); - - // Optional logging - LogRateLimitExceeded( - logger, - context.HttpContext.Request.Path, - context.HttpContext.User.Identity?.Name ?? "anonymous", - context.HttpContext.Connection.RemoteIpAddress); - return; - } - - await context.HttpContext.Response.WriteAsync("Rate limit exceeded. Please try again later.", cancellationToken); - - LogRateLimitExceeded( - logger, - context.HttpContext.Request.Path, - context.HttpContext.User.Identity?.Name ?? "anonymous", - context.HttpContext.Connection.RemoteIpAddress); - }; - }); - - if (!builder.Environment.IsDevelopment()) - { - builder.Services.AddHttpClient(client => - { - //set BaseAddress, MediaType, UserAgent - client.SetDefaultSettings(); - - client.UseBasicAuthentication(configuration["AuthMessageSender:APIKey"], configuration["AuthMessageSender:SecretKey"]); - }); - } - - if (!builder.Environment.IsDevelopment()) - { - builder.Services.AddAuthentication() - .AddMicrosoftAccount(microsoftoptions => - { - microsoftoptions.ClientId = configuration["authentication:microsoft:clientid"] ?? throw new InvalidOperationException("authentication:microsoft:clientid unexpectedly null"); - microsoftoptions.ClientSecret = configuration["authentication:microsoft:clientsecret"] ?? throw new InvalidOperationException("authentication:microsoft:clientsecret unexpectedly null"); - }) - .AddGitHub(o => - { - o.ClientId = configuration["authentication:github:clientId"] ?? throw new InvalidOperationException("github:clientId unexpectedly null"); - o.ClientSecret = configuration["authentication:github:clientSecret"] ?? throw new InvalidOperationException("github:clientSecret unexpectedly null"); - - // Grants access to read a user's profile data. - // https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps - o.Scope.Add("read:user"); - }); - } - - WebApplication app = builder.Build(); - - if (profilerSkippedUnsupportedPlatform) - LogSkippingUnsupportedAzureMonitorProfiler( - app.Services.GetRequiredService>(), - RuntimeInformation.OSDescription); - - // Configure the HTTP request pipeline. - if (!app.Environment.IsDevelopment()) - { - SiteSettings siteSettings = app.Services.GetRequiredService>().Value; - if (!Uri.TryCreate(siteSettings.BaseUrl, UriKind.Absolute, out Uri? configuredBaseUri)) - { - throw new InvalidOperationException($"Invalid {SiteSettings.SectionName}:{nameof(SiteSettings.BaseUrl)} value: '{siteSettings.BaseUrl}'."); - } - string apexHost = configuredBaseUri.Host.StartsWith("www.", StringComparison.OrdinalIgnoreCase) - ? configuredBaseUri.Host[4..] - : configuredBaseUri.Host; - string wwwHost = $"www.{apexHost}"; - Uri apexUri = new UriBuilder(configuredBaseUri) { Host = apexHost }.Uri; - string redirectScheme = apexUri.Scheme; - HostString redirectHost = new HostString(apexUri.Authority); - - app.UseExceptionHandler(exceptionApp => - { - exceptionApp.Run(async context => - { - var exceptionFeature = context.Features.Get(); - var logger = context.RequestServices.GetRequiredService>(); - LogUnhandledException(logger, exceptionFeature?.Error, context.Request.Path); - - if (context.Request.Path.StartsWithSegments("/mcp")) - { - await McpJsonRpcResponseWriter.WriteErrorAsync( - context.Response, - StatusCodes.Status500InternalServerError, - -32603, - "An unexpected error occurred while processing the MCP request.", - context.RequestAborted); - } - else if (context.Request.Path.StartsWithSegments("/api")) - { - context.Response.StatusCode = 500; - context.Response.ContentType = "application/json"; - await context.Response.WriteAsJsonAsync(new { error = "An unexpected error occurred" }); - } - else - { - context.Response.Redirect("/Home/Error?statusCode=500"); - } - }); - }); - // Skip manual UseForwardedHeaders when ASPNETCORE_FORWARDEDHEADERS_ENABLED=true; - // the built-in startup filter already called it before this pipeline runs. - if (!string.Equals(app.Configuration["ASPNETCORE_FORWARDEDHEADERS_ENABLED"], "true", StringComparison.OrdinalIgnoreCase)) - { - app.UseForwardedHeaders(); - } - - // Build dynamic CSP — TryDotNet origin comes from runtime config - string? tryDotNetOrigin = app.Configuration["TryDotNet:Origin"]; - string tryDotNetSources = string.Empty; - if (!string.IsNullOrWhiteSpace(tryDotNetOrigin)) - { - if (Uri.TryCreate(tryDotNetOrigin, UriKind.Absolute, out Uri? tryDotNetUri)) - { - tryDotNetSources = $" {tryDotNetUri.GetLeftPart(UriPartial.Authority)}"; - } - else - { - LogIgnoringInvalidTryDotNetOrigin(app.Logger, tryDotNetOrigin); - } - } - - string csp = string.Join("; ", - $"default-src 'self'", - $"script-src 'self' 'unsafe-inline' cdn.jsdelivr.net www.clarity.ms www.googletagmanager.com js.monitor.azure.com https://hcaptcha.com https://*.hcaptcha.com{tryDotNetSources}", - $"style-src 'self' 'nonce-{CspNonce.Placeholder}' cdnjs.cloudflare.com fonts.googleapis.com https://hcaptcha.com https://*.hcaptcha.com", - $"style-src-attr 'unsafe-inline'", - $"font-src 'self' fonts.gstatic.com cdnjs.cloudflare.com", - $"img-src 'self' data: https:", - $"connect-src 'self' https://hcaptcha.com https://*.hcaptcha.com https://api.pwnedpasswords.com https://*.algolia.net https://*.algolianet.com https://*.google-analytics.com https://*.clarity.ms https://*.in.applicationinsights.azure.com{GetApplicationInsightsCspSources(app.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"], app.Logger)}{tryDotNetSources}", - $"frame-src https://hcaptcha.com https://*.hcaptcha.com https://newassets.hcaptcha.com{tryDotNetSources}", - $"worker-src blob:", - $"frame-ancestors 'none'", - $"base-uri 'self'", - $"form-action 'self' https://login.microsoftonline.com https://github.com" - ); - - app.UseSecurityHeadersMiddleware(new SecurityHeadersBuilder() - .AddDefaultSecurePolicy() - .AddContentSecurityPolicy(csp)); - - // Redirect configured www host to configured apex host (permanent 301). - // Must be after UseForwardedHeaders so the Host header reflects the real hostname. - app.Use(async (context, next) => - { - if (string.Equals(context.Request.Host.Host, wwwHost, StringComparison.OrdinalIgnoreCase)) - { - string redirectUrl = UriHelper.BuildAbsolute(redirectScheme, redirectHost, context.Request.PathBase, context.Request.Path, context.Request.QueryString); - context.Response.Redirect(redirectUrl, permanent: true); - return; - } - await next(context); - }); - } - else - { - app.UseDeveloperExceptionPage(); - if (!string.Equals(app.Configuration["ASPNETCORE_FORWARDEDHEADERS_ENABLED"], "true", StringComparison.OrdinalIgnoreCase)) - { - app.UseForwardedHeaders(); - } - } - - app.MapHealthChecks("/health").DisableRateLimiting(); - app.MapHealthChecks("/alive", new HealthCheckOptions - { - Predicate = r => r.Tags.Contains("live") - }).DisableRateLimiting(); - - if (!app.Environment.IsDevelopment()) - { - app.UseHttpsRedirection(); - } - app.UseStaticFiles(); - - app.UseRouting(); - - app.UseWhen( - context => context.Request.Path.StartsWithSegments("/mcp"), - branch => branch.UseCors("McpInspectorCors")); - - app.UseAuthentication(); - - // /mcp uses a named non-default scheme. Normalize the principal before - // rate limiting so valid MCP requests partition by MCP user while - // missing/invalid bearer requests fall back to the anonymous/IP bucket - // instead of inheriting the site's cookie principal. - app.UseWhen( - context => context.Request.Path.StartsWithSegments("/mcp"), - branch => branch.UseMiddleware()); - - app.UseRateLimiter(); - - app.UseAuthorization(); - app.UseOutputCache(); - - app.UseMiddleware(); - - app.MapRazorPages(); - app.MapDefaultControllerRoute(); - - app.MapMethods("/mcp", [HttpMethods.Get], (HttpResponse response) => - { - response.Headers.Append("Allow", HttpMethods.Post); - response.Headers.CacheControl = "no-store"; - return Results.StatusCode(StatusCodes.Status405MethodNotAllowed); - }); - - app.MapMcp("/mcp") - .RequireAuthorization("McpPolicy") - .RequireRateLimiting(McpRateLimiterPolicy.PolicyName); - - app.Map("/.well-known", (HttpResponse response) => - { - response.Headers.CacheControl = "no-store"; - return Results.NotFound(); - }).DisableRateLimiting(); - - app.Map("/.well-known/{**path}", (HttpResponse response) => - { - response.Headers.CacheControl = "no-store"; - return Results.NotFound(); - }).DisableRateLimiting(); - - app.MapFallbackToController("Index", "Home"); - - // Validate sitemap data at startup — logs errors but allows startup to continue - var siteMappingService = app.Services.GetRequiredService(); - var logger = app.Services.GetRequiredService>(); - - try - { - SitemapXmlHelpers.EnsureSitemapHealthy(siteMappingService.SiteMappings.ToList()); - LogSitemapValidationSucceeded(logger); - } - catch (InvalidOperationException ex) - { - LogSitemapValidationFailed(logger, ex); - // Continue startup even if sitemap validation fails - } - - app.Run(); - } - - private static bool IsMcpTransportRequest(HttpRequest request) => - HttpMethods.IsPost(request.Method) && request.Path == "/mcp"; - - [LoggerMessage(Level = LogLevel.Warning, Message = "Rate limit exceeded on {Path}. User: {User}, IP: {IpAddress}")] - private static partial void LogRateLimitExceeded(ILogger logger, PathString path, string user, System.Net.IPAddress? ipAddress); - - [LoggerMessage(Level = LogLevel.Error, Message = "Unhandled exception on {Path}")] - private static partial void LogUnhandledException(ILogger logger, Exception? exception, PathString path); - - [LoggerMessage(Level = LogLevel.Information, Message = "Sitemap validation completed successfully during application startup")] - private static partial void LogSitemapValidationSucceeded(ILogger logger); - - [LoggerMessage(Level = LogLevel.Error, Message = "Failed to validate sitemap during application startup")] - private static partial void LogSitemapValidationFailed(ILogger logger, Exception exception); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Ignoring invalid TryDotNet origin in CSP: {Origin}")] - private static partial void LogIgnoringInvalidTryDotNetOrigin(ILogger logger, string origin); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Azure Monitor profiler is not supported on this platform ({Platform}). Skipping profiler registration and continuing with Azure Monitor telemetry export.")] - private static partial void LogSkippingUnsupportedAzureMonitorProfiler(ILogger logger, string platform); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Application Insights connection string has a non-HTTPS or unparseable IngestionEndpoint value ({Endpoint}); omitting from CSP connect-src.")] - private static partial void LogInvalidApplicationInsightsIngestionEndpoint(ILogger logger, string? endpoint); - - private static string GetApplicationInsightsCspSources(string? connectionString, ILogger? logger = null) - { - if (string.IsNullOrWhiteSpace(connectionString)) - { - return string.Empty; - } - - string? ingestionEndpoint = GetConnectionStringValue(connectionString, "IngestionEndpoint"); - if (string.IsNullOrWhiteSpace(ingestionEndpoint) - || !Uri.TryCreate(ingestionEndpoint, UriKind.Absolute, out Uri? ingestionUri) - || ingestionUri.Scheme != Uri.UriSchemeHttps) - { - if (logger is not null) - { - LogInvalidApplicationInsightsIngestionEndpoint(logger, ingestionEndpoint); - } - return string.Empty; - } - - return $" {ingestionUri.GetLeftPart(UriPartial.Authority)}"; - } - - private static string? GetConnectionStringValue(string connectionString, string key) - { - foreach (string segment in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - int separatorIndex = segment.IndexOf('='); - if (separatorIndex <= 0) - { - continue; - } - - string currentKey = segment[..separatorIndex]; - if (!currentKey.Equals(key, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - return segment[(separatorIndex + 1)..].Trim('"'); - } - - return null; - } -} +using System.Security.Claims; +using System.Runtime.InteropServices; +using System.Threading.RateLimiting; +using ModelContextProtocol.Protocol; +using EssentialCSharp.Chat.Common.Extensions; +using EssentialCSharp.Web.Areas.Identity.Data; +using EssentialCSharp.Web.Areas.Identity.Services.PasswordValidators; +using EssentialCSharp.Web.Auth; +using EssentialCSharp.Web.Controllers; +using EssentialCSharp.Web.Data; +using EssentialCSharp.Web.Extensions; +using EssentialCSharp.Web.Helpers; +using EssentialCSharp.Web.Middleware; +using EssentialCSharp.Web.Services; +using EssentialCSharp.Web.Services.Referrals; +using EssentialCSharp.Web.Tools; +using Mailjet.Client; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.RateLimiting; +using Azure.Monitor.OpenTelemetry.AspNetCore; +using Azure.Monitor.OpenTelemetry.Profiler; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenTelemetry; +using OpenTelemetry.Instrumentation.AspNetCore; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace EssentialCSharp.Web; + +public partial class Program +{ + private static void Main(string[] args) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + + // Health checks (liveness/readiness probes for ACA and standalone hosting) + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + // Prefer Aspire's OTLP endpoint when present; otherwise export to Azure Monitor. + // This keeps local traces in the dashboard even when a developer has an App Insights connection string configured. + string? appInsightsConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]; + bool useOtlp = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + bool useAzureMonitor = !useOtlp && !string.IsNullOrWhiteSpace(appInsightsConnectionString); + bool profilerSupportedPlatform = OperatingSystem.IsWindows() || OperatingSystem.IsLinux(); + bool profilerSkippedUnsupportedPlatform = false; + + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + // Health probe paths excluded from tracing unconditionally — applies to both + // manual instrumentation and Azure Monitor's auto-instrumentation. + builder.Services.Configure(options => + { + options.Filter = ctx => + !ctx.Request.Path.StartsWithSegments("/health") + && !ctx.Request.Path.StartsWithSegments("/alive"); + // EnrichWithHttpResponse fires after the authentication middleware has run, + // so HttpContext.User is populated and IsAuthenticated is reliable. + options.EnrichWithHttpResponse = (activity, response) => + { + var user = response.HttpContext.User; + if (user?.Identity?.IsAuthenticated != true) + { + return; + } + + string? userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + if (!string.IsNullOrWhiteSpace(userId)) + { + activity.SetTag("enduser.id", userId); + } + }; + }); + + var otel = builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + // Azure Monitor auto-instruments ASP.NET Core + HttpClient metrics; only add + // them manually when using OTLP so we don't register duplicate meter listeners. + if (!useAzureMonitor) + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation(); + } + // Runtime metrics are not included in the Azure Monitor distro. + metrics.AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName); + // Azure Monitor distro auto-instruments tracing; add manually only for OTLP path. + if (!useAzureMonitor) + { + tracing.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddSqlClientInstrumentation(); + } + }); + + if (useAzureMonitor) + { + // Azure Monitor export is supported cross-platform, but the profiler currently only + // supports Windows and Linux. + var azureMonitor = otel.UseAzureMonitor(); + if (profilerSupportedPlatform) + azureMonitor.AddAzureMonitorProfiler(); + else + profilerSkippedUnsupportedPlatform = true; + } + else if (useOtlp) + otel.UseOtlpExporter(); + + // HttpClient defaults — standard retry/circuit breaker for all named clients. + builder.Services.ConfigureHttpClientDefaults(http => http.AddStandardResilienceHandler()); + + builder.Services.AddHttpClient("HaveIBeenPwned", c => + { + c.BaseAddress = new Uri("https://api.pwnedpasswords.com/"); + c.DefaultRequestHeaders.UserAgent.ParseAdd("EssentialCSharp.Web/1.0"); + // Short timeout: this check is advisory/fail-open, so cap latency impact on auth flows. + c.Timeout = TimeSpan.FromSeconds(3); + }); + + builder.Services.AddTrustedForwardedHeaders(builder.Configuration, builder.Environment); + builder.Services.AddHttpsRedirection(options => + { + options.HttpsPort = 443; + }); + + ConfigurationManager configuration = builder.Configuration; + string connectionString = builder.Configuration.GetConnectionString("EssentialCSharpWebContextConnection") ?? throw new InvalidOperationException("Connection string 'EssentialCSharpWebContextConnection' not found."); + + builder.Services.AddDbContext(options => options.UseSqlServer(connectionString, sql => sql.EnableRetryOnFailure(5))); + + if (builder.Environment.IsDevelopment()) + { + // Must run before DataProtectionHostedService, which reads DataProtectionKeys. + builder.Services.AddHostedService(); + } + + // Data Protection — persist keys to SQL Server so they survive container restarts. + // SetApplicationName ensures the discriminator is stable across container hostname changes. + var dpBuilder = builder.Services.AddDataProtection() + .SetApplicationName("EssentialCSharpWeb") + .PersistKeysToDbContext(); + var keyVaultKeyUri = builder.Configuration["DataProtection:AzureKeyVaultKeyUri"]; + if (!string.IsNullOrEmpty(keyVaultKeyUri)) + { + dpBuilder.ProtectKeysWithAzureKeyVault(new Uri(keyVaultKeyUri), new Azure.Identity.DefaultAzureCredential()); + } + else if (!builder.Environment.IsDevelopment()) + { + throw new InvalidOperationException( + "DataProtection:AzureKeyVaultKeyUri is required in non-Development environments. " + + "Set the DataProtection__AzureKeyVaultKeyUri environment variable to the Key Vault key URI."); + } + + builder.Services.AddDefaultIdentity(options => + { + // Password settings + options.User.RequireUniqueEmail = true; + options.Password.RequiredLength = PasswordRequirementOptions.PasswordMinimumLength; + options.Password.RequireDigit = false; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequireUppercase = false; + options.Password.RequireLowercase = false; + options.Password.RequiredUniqueChars = 1; + + options.SignIn.RequireConfirmedEmail = true; + options.SignIn.RequireConfirmedAccount = true; + + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(30); + options.Lockout.MaxFailedAccessAttempts = 3; + + //TODO: Implement IProtectedUserStore + //options.Stores.ProtectPersonalData = true; + }) + .AddEntityFrameworkStores() + .AddPasswordValidator>() + .AddPasswordValidator>() + .AddPasswordValidator>(); + + builder.Configuration + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddUserSecrets() + .AddEnvironmentVariables(); + + builder.Services.ConfigureApplicationCookie(options => + { + options.Cookie.HttpOnly = true; + options.ExpireTimeSpan = TimeSpan.FromMinutes(60); + options.SlidingExpiration = true; + // API endpoints must return 401/403 instead of redirecting to the login page. + // Cookie auth's default behavior (302 redirect) causes fetch() to follow the + // redirect, eventually hitting the fallback controller and returning a 404. + options.Events.OnRedirectToLogin = context => + { + if (context.Request.Path.StartsWithSegments("/api") + || context.Request.Path.StartsWithSegments("/mcp")) + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + else + context.Response.Redirect(context.RedirectUri); + return Task.CompletedTask; + }; + options.Events.OnRedirectToAccessDenied = context => + { + if (context.Request.Path.StartsWithSegments("/api") + || context.Request.Path.StartsWithSegments("/mcp")) + context.Response.StatusCode = StatusCodes.Status403Forbidden; + else + context.Response.Redirect(context.RedirectUri); + return Task.CompletedTask; + }; + }); + + builder.Services.Configure(option => + { + // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 + // Minimum recommended is currently 210,000 iterations for pdkdf2-sha512 as of October 27, 2023 + option.IterationCount = 500000; + }); + builder.Services.AddScoped>(provider => + { + if (!provider.GetRequiredService>().SupportsUserEmail) + { + throw new NotSupportedException("The default UI requires a user store with email support."); + } + return (IUserEmailStore)provider.GetRequiredService>(); + }); + + builder.Services.AddScoped>(provider => + { + if (provider.GetRequiredService>() is IUserPasswordStore userPasswordStore) + { + return userPasswordStore; + } + throw new NotSupportedException("The default UI requires a user store with password support."); + }); + + builder.Services.AddControllersWithViews(options => + { + options.Filters.Add(new Microsoft.AspNetCore.Mvc.AutoValidateAntiforgeryTokenAttribute()); + }); + + if (!builder.Environment.IsDevelopment()) + { + builder.Services.AddTransient(); + } + builder.Services.Configure(builder.Configuration.GetSection(AuthMessageSenderOptions.AuthMessageSender)); + builder.Services.Configure(builder.Configuration.GetSection(SiteSettings.SectionName)); + + // Add services to the container. + builder.Services.AddRazorPages(); + builder.Services.AddOutputCache(); + builder.Services.AddCaptchaService(builder.Configuration.GetSection(CaptchaOptions.CaptchaSender)); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + // One instance serves both the query interface and the startup hook that builds the cache. + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); + builder.Services.AddOptions() + .BindConfiguration(ReadingOptions.SectionName) + .ValidateDataAnnotations() + .ValidateOnStart(); + builder.Services.AddSingleton, ReadingOptionsValidator>(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // Add AI Chat services using configuration-driven backend selection. + builder.Services.AddConfiguredChatServices(configuration); + + // MCP server — always enabled, authenticated via opaque DB-backed tokens. + builder.Services.AddScoped(); + + builder.Services.AddMemoryCache(); + builder.Services.AddSingleton(_ => new ResponseIdValidationService()); + + builder.Services.AddAuthentication() + .AddScheme( + McpBearerAuthentication.Scheme, _ => { }); + + builder.Services.AddAuthorization(options => + options.AddPolicy("McpPolicy", policy => + policy.AddAuthenticationSchemes(McpBearerAuthentication.Scheme) + .RequireAuthenticatedUser())); + + builder.Services.AddCors(options => + options.AddPolicy("McpInspectorCors", policy => + policy.SetIsOriginAllowed(origin => + Uri.TryCreate(origin, UriKind.Absolute, out Uri? originUri) + && originUri.IsLoopback + && (originUri.Scheme == Uri.UriSchemeHttp || originUri.Scheme == Uri.UriSchemeHttps)) + .AllowAnyHeader() + .AllowAnyMethod() + .WithExposedHeaders("Mcp-Session-Id"))); + + builder.Services.AddSingleton(); + + builder.Services.AddMcpServer() + .WithHttpTransport(options => options.Stateless = true) + .WithTools() + .WithTools() + .WithTools() + .WithTools(); + + // Add Rate Limiting for API endpoints + builder.Services.AddRateLimiter(options => + { + // Global rate limiter for site requests by authenticated user ID or anonymous IP. + // MCP transport requests use a dedicated named policy attached to MapMcp("/mcp"). + options.GlobalLimiter = PartitionedRateLimiter.Create(httpContext => + { + if (httpContext.Request.Path.StartsWithSegments("/.well-known")) + return RateLimitPartition.GetNoLimiter("well-known"); + + if (IsMcpTransportRequest(httpContext.Request)) + return RateLimitPartition.GetNoLimiter("mcp-transport"); + + // Reading API endpoints are limited by their own named policies. + if (httpContext.Request.Path.StartsWithSegments("/api/reading")) + return RateLimitPartition.GetNoLimiter("reading-api"); + + var partitionKey = httpContext.User.Identity?.IsAuthenticated == true + ? httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown-user" + : httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"; + + return RateLimitPartition.GetFixedWindowLimiter( + partitionKey: partitionKey, + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 30, // requests per window + Window = TimeSpan.FromMinutes(1), // minute window + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 0 // No queuing - immediate rejection for better UX + }); + }); + + options.AddPolicy("ChatEndpoint", httpContext => + { + // Partitioned per-user (when authenticated) or per-IP (anonymous) + var partitionKey = httpContext.User.Identity?.IsAuthenticated == true + ? $"chat-user:{httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown-user"}" + : $"chat-ip:{httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"}"; + + return RateLimitPartition.GetFixedWindowLimiter( + partitionKey: partitionKey, + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 15, + Window = TimeSpan.FromMinutes(1), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 0 + }); + }); + + // Reading tracker: per-user limit for authenticated endpoints, looser per-IP limit for anonymous book-stats. + options.AddPolicy(ReadingController.WriteRateLimitPolicy, httpContext => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: $"reading-user:{httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"}", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 20, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0 + })); + options.AddPolicy(ReadingController.PublicRateLimitPolicy, httpContext => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: $"reading-ip:{httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"}", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 60, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0 + })); + + // Combined per-minute burst (10/min) + per-hour cap (150/hr) for book content pages. + // A scraper cycling through the full ~400-page book needs 2+ hours at minimum. + // See Services/ContentRateLimiterPolicy.cs for implementation. + options.AddPolicy("content", new ContentRateLimiterPolicy()); + options.AddPolicy(McpRateLimiterPolicy.PolicyName, new McpRateLimiterPolicy()); + + // Custom response when rate limit is exceeded + options.OnRejected = async (context, cancellationToken) => + { + context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + int? retryAfterSeconds = RateLimitingResponseHelpers.ApplyRetryAfterHeader( + context.HttpContext.Response, + context.Lease); + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + + if (context.HttpContext.Request.Path.StartsWithSegments("/api/chat")) + { + // Custom rejection handling logic + context.HttpContext.Response.ContentType = "application/json"; + + Dictionary errorResponse = new() + { + ["error"] = "Rate limit exceeded. Please wait before sending another message.", + ["statusCode"] = 429 + }; + if (retryAfterSeconds is int retryAfter) + errorResponse["retryAfter"] = retryAfter; + + await context.HttpContext.Response.WriteAsync( + System.Text.Json.JsonSerializer.Serialize(errorResponse), + cancellationToken); + + // Optional logging + LogRateLimitExceeded( + logger, + context.HttpContext.Request.Path, + context.HttpContext.User.Identity?.Name ?? "anonymous", + context.HttpContext.Connection.RemoteIpAddress); + return; + } + + await context.HttpContext.Response.WriteAsync("Rate limit exceeded. Please try again later.", cancellationToken); + + LogRateLimitExceeded( + logger, + context.HttpContext.Request.Path, + context.HttpContext.User.Identity?.Name ?? "anonymous", + context.HttpContext.Connection.RemoteIpAddress); + }; + }); + + if (!builder.Environment.IsDevelopment()) + { + builder.Services.AddHttpClient(client => + { + //set BaseAddress, MediaType, UserAgent + client.SetDefaultSettings(); + + client.UseBasicAuthentication(configuration["AuthMessageSender:APIKey"], configuration["AuthMessageSender:SecretKey"]); + }); + } + + if (!builder.Environment.IsDevelopment()) + { + builder.Services.AddAuthentication() + .AddMicrosoftAccount(microsoftoptions => + { + microsoftoptions.ClientId = configuration["authentication:microsoft:clientid"] ?? throw new InvalidOperationException("authentication:microsoft:clientid unexpectedly null"); + microsoftoptions.ClientSecret = configuration["authentication:microsoft:clientsecret"] ?? throw new InvalidOperationException("authentication:microsoft:clientsecret unexpectedly null"); + }) + .AddGitHub(o => + { + o.ClientId = configuration["authentication:github:clientId"] ?? throw new InvalidOperationException("github:clientId unexpectedly null"); + o.ClientSecret = configuration["authentication:github:clientSecret"] ?? throw new InvalidOperationException("github:clientSecret unexpectedly null"); + + // Grants access to read a user's profile data. + // https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps + o.Scope.Add("read:user"); + }); + } + + WebApplication app = builder.Build(); + + if (profilerSkippedUnsupportedPlatform) + LogSkippingUnsupportedAzureMonitorProfiler( + app.Services.GetRequiredService>(), + RuntimeInformation.OSDescription); + + // Configure the HTTP request pipeline. + if (!app.Environment.IsDevelopment()) + { + SiteSettings siteSettings = app.Services.GetRequiredService>().Value; + if (!Uri.TryCreate(siteSettings.BaseUrl, UriKind.Absolute, out Uri? configuredBaseUri)) + { + throw new InvalidOperationException($"Invalid {SiteSettings.SectionName}:{nameof(SiteSettings.BaseUrl)} value: '{siteSettings.BaseUrl}'."); + } + string apexHost = configuredBaseUri.Host.StartsWith("www.", StringComparison.OrdinalIgnoreCase) + ? configuredBaseUri.Host[4..] + : configuredBaseUri.Host; + string wwwHost = $"www.{apexHost}"; + Uri apexUri = new UriBuilder(configuredBaseUri) { Host = apexHost }.Uri; + string redirectScheme = apexUri.Scheme; + HostString redirectHost = new HostString(apexUri.Authority); + + app.UseExceptionHandler(exceptionApp => + { + exceptionApp.Run(async context => + { + var exceptionFeature = context.Features.Get(); + var logger = context.RequestServices.GetRequiredService>(); + LogUnhandledException(logger, exceptionFeature?.Error, context.Request.Path); + + if (context.Request.Path.StartsWithSegments("/mcp")) + { + await McpJsonRpcResponseWriter.WriteErrorAsync( + context.Response, + StatusCodes.Status500InternalServerError, + -32603, + "An unexpected error occurred while processing the MCP request.", + context.RequestAborted); + } + else if (context.Request.Path.StartsWithSegments("/api")) + { + context.Response.StatusCode = 500; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync(new { error = "An unexpected error occurred" }); + } + else + { + context.Response.Redirect("/Home/Error?statusCode=500"); + } + }); + }); + // Skip manual UseForwardedHeaders when ASPNETCORE_FORWARDEDHEADERS_ENABLED=true; + // the built-in startup filter already called it before this pipeline runs. + if (!string.Equals(app.Configuration["ASPNETCORE_FORWARDEDHEADERS_ENABLED"], "true", StringComparison.OrdinalIgnoreCase)) + { + app.UseForwardedHeaders(); + } + + // Build dynamic CSP — TryDotNet origin comes from runtime config + string? tryDotNetOrigin = app.Configuration["TryDotNet:Origin"]; + string tryDotNetSources = string.Empty; + if (!string.IsNullOrWhiteSpace(tryDotNetOrigin)) + { + if (Uri.TryCreate(tryDotNetOrigin, UriKind.Absolute, out Uri? tryDotNetUri)) + { + tryDotNetSources = $" {tryDotNetUri.GetLeftPart(UriPartial.Authority)}"; + } + else + { + LogIgnoringInvalidTryDotNetOrigin(app.Logger, tryDotNetOrigin); + } + } + + string csp = string.Join("; ", + $"default-src 'self'", + $"script-src 'self' 'unsafe-inline' cdn.jsdelivr.net www.clarity.ms www.googletagmanager.com js.monitor.azure.com https://hcaptcha.com https://*.hcaptcha.com{tryDotNetSources}", + $"style-src 'self' 'nonce-{CspNonce.Placeholder}' cdnjs.cloudflare.com fonts.googleapis.com https://hcaptcha.com https://*.hcaptcha.com", + $"style-src-attr 'unsafe-inline'", + $"font-src 'self' fonts.gstatic.com cdnjs.cloudflare.com", + $"img-src 'self' data: https:", + $"connect-src 'self' https://hcaptcha.com https://*.hcaptcha.com https://api.pwnedpasswords.com https://*.algolia.net https://*.algolianet.com https://*.google-analytics.com https://*.clarity.ms https://*.in.applicationinsights.azure.com{GetApplicationInsightsCspSources(app.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"], app.Logger)}{tryDotNetSources}", + $"frame-src https://hcaptcha.com https://*.hcaptcha.com https://newassets.hcaptcha.com{tryDotNetSources}", + $"worker-src blob:", + $"frame-ancestors 'none'", + $"base-uri 'self'", + $"form-action 'self' https://login.microsoftonline.com https://github.com" + ); + + app.UseSecurityHeadersMiddleware(new SecurityHeadersBuilder() + .AddDefaultSecurePolicy() + .AddContentSecurityPolicy(csp)); + + // Redirect configured www host to configured apex host (permanent 301). + // Must be after UseForwardedHeaders so the Host header reflects the real hostname. + app.Use(async (context, next) => + { + if (string.Equals(context.Request.Host.Host, wwwHost, StringComparison.OrdinalIgnoreCase)) + { + string redirectUrl = UriHelper.BuildAbsolute(redirectScheme, redirectHost, context.Request.PathBase, context.Request.Path, context.Request.QueryString); + context.Response.Redirect(redirectUrl, permanent: true); + return; + } + await next(context); + }); + } + else + { + app.UseDeveloperExceptionPage(); + if (!string.Equals(app.Configuration["ASPNETCORE_FORWARDEDHEADERS_ENABLED"], "true", StringComparison.OrdinalIgnoreCase)) + { + app.UseForwardedHeaders(); + } + } + + app.MapHealthChecks("/health").DisableRateLimiting(); + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }).DisableRateLimiting(); + + if (!app.Environment.IsDevelopment()) + { + app.UseHttpsRedirection(); + } + app.UseStaticFiles(); + + app.UseRouting(); + + app.UseWhen( + context => context.Request.Path.StartsWithSegments("/mcp"), + branch => branch.UseCors("McpInspectorCors")); + + app.UseAuthentication(); + + // /mcp uses a named non-default scheme. Normalize the principal before + // rate limiting so valid MCP requests partition by MCP user while + // missing/invalid bearer requests fall back to the anonymous/IP bucket + // instead of inheriting the site's cookie principal. + app.UseWhen( + context => context.Request.Path.StartsWithSegments("/mcp"), + branch => branch.UseMiddleware()); + + app.UseRateLimiter(); + + app.UseAuthorization(); + app.UseOutputCache(); + + app.UseMiddleware(); + + app.MapRazorPages(); + app.MapDefaultControllerRoute(); + + app.MapMethods("/mcp", [HttpMethods.Get], (HttpResponse response) => + { + response.Headers.Append("Allow", HttpMethods.Post); + response.Headers.CacheControl = "no-store"; + return Results.StatusCode(StatusCodes.Status405MethodNotAllowed); + }); + + app.MapMcp("/mcp") + .RequireAuthorization("McpPolicy") + .RequireRateLimiting(McpRateLimiterPolicy.PolicyName); + + app.Map("/.well-known", (HttpResponse response) => + { + response.Headers.CacheControl = "no-store"; + return Results.NotFound(); + }).DisableRateLimiting(); + + app.Map("/.well-known/{**path}", (HttpResponse response) => + { + response.Headers.CacheControl = "no-store"; + return Results.NotFound(); + }).DisableRateLimiting(); + + app.MapFallbackToController("Index", "Home"); + + // Validate sitemap data at startup — logs errors but allows startup to continue + var siteMappingService = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService>(); + + try + { + SitemapXmlHelpers.EnsureSitemapHealthy(siteMappingService.SiteMappings.ToList()); + LogSitemapValidationSucceeded(logger); + } + catch (InvalidOperationException ex) + { + LogSitemapValidationFailed(logger, ex); + // Continue startup even if sitemap validation fails + } + + app.Run(); + } + + private static bool IsMcpTransportRequest(HttpRequest request) => + HttpMethods.IsPost(request.Method) && request.Path == "/mcp"; + + [LoggerMessage(Level = LogLevel.Warning, Message = "Rate limit exceeded on {Path}. User: {User}, IP: {IpAddress}")] + private static partial void LogRateLimitExceeded(ILogger logger, PathString path, string user, System.Net.IPAddress? ipAddress); + + [LoggerMessage(Level = LogLevel.Error, Message = "Unhandled exception on {Path}")] + private static partial void LogUnhandledException(ILogger logger, Exception? exception, PathString path); + + [LoggerMessage(Level = LogLevel.Information, Message = "Sitemap validation completed successfully during application startup")] + private static partial void LogSitemapValidationSucceeded(ILogger logger); + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to validate sitemap during application startup")] + private static partial void LogSitemapValidationFailed(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Ignoring invalid TryDotNet origin in CSP: {Origin}")] + private static partial void LogIgnoringInvalidTryDotNetOrigin(ILogger logger, string origin); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Azure Monitor profiler is not supported on this platform ({Platform}). Skipping profiler registration and continuing with Azure Monitor telemetry export.")] + private static partial void LogSkippingUnsupportedAzureMonitorProfiler(ILogger logger, string platform); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Application Insights connection string has a non-HTTPS or unparseable IngestionEndpoint value ({Endpoint}); omitting from CSP connect-src.")] + private static partial void LogInvalidApplicationInsightsIngestionEndpoint(ILogger logger, string? endpoint); + + private static string GetApplicationInsightsCspSources(string? connectionString, ILogger? logger = null) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + return string.Empty; + } + + string? ingestionEndpoint = GetConnectionStringValue(connectionString, "IngestionEndpoint"); + if (string.IsNullOrWhiteSpace(ingestionEndpoint) + || !Uri.TryCreate(ingestionEndpoint, UriKind.Absolute, out Uri? ingestionUri) + || ingestionUri.Scheme != Uri.UriSchemeHttps) + { + if (logger is not null) + { + LogInvalidApplicationInsightsIngestionEndpoint(logger, ingestionEndpoint); + } + return string.Empty; + } + + return $" {ingestionUri.GetLeftPart(UriPartial.Authority)}"; + } + + private static string? GetConnectionStringValue(string connectionString, string key) + { + foreach (string segment in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + int separatorIndex = segment.IndexOf('='); + if (separatorIndex <= 0) + { + continue; + } + + string currentKey = segment[..separatorIndex]; + if (!currentKey.Equals(key, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + return segment[(separatorIndex + 1)..].Trim('"'); + } + + return null; + } +} diff --git a/EssentialCSharp.Web/Services/ReadingOptions.cs b/EssentialCSharp.Web/Services/ReadingOptions.cs index 6753385a..cef19ede 100644 --- a/EssentialCSharp.Web/Services/ReadingOptions.cs +++ b/EssentialCSharp.Web/Services/ReadingOptions.cs @@ -1,21 +1,74 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.Options; + namespace EssentialCSharp.Web.Services; /// /// Tunables for the reading-time tracker (mirrors Kindle's ReadingTimer values). +/// This is the single source for every reading constant: the client receives the relevant +/// values through window.READING_CONTEXT and keeps no copies of its own. /// public sealed class ReadingOptions { public const string SectionName = "Reading"; /// Intervals faster than this many words per minute are implausible and are ignored. - public int HardCutoffWpm { get; set; } = 900; + [Range(1, 10_000)] + public int HardCutoffWpm { get; init; } = 900; /// /// Intervals slower than this fraction of the reader's reference WPM are credited /// as if they had been read at exactly that fraction of the reference speed. /// - public double SlowOutlierFactor { get; set; } = 0.25; + [Range(0.0, 1.0)] + public double SlowOutlierFactor { get; init; } = 0.25; /// Maximum number of intervals accepted in a single request. - public int MaxIntervalsPerRequest { get; set; } = 50; + [Range(1, 1_000)] + public int MaxIntervalsPerRequest { get; init; } = 50; + + /// Reading speed (words per minute) the client assumes until the reader has a derived speed. + [Range(1, 10_000)] + public int DefaultWpm { get; init; } = 200; + + /// + /// Client-side ceiling on how fast scrolling may be credited as reading (words per minute). + /// Must not exceed , otherwise the server would discard intervals the client credits. + /// + [Range(1, 10_000)] + public int MaxCreditWpm { get; init; } = 800; + + /// Seconds without user activity after which the client stops counting active time. + [Range(1, 86_400)] + public int IdleThresholdSeconds { get; init; } = 300; + + /// Active seconds after which the client flushes an interval while the reader stays on the page. + [Range(1, 3_600)] + public int PeriodicFlushActiveSeconds { get; init; } = 60; + + /// Largest activeSeconds the server accepts for one interval; larger values are rejected. + [Range(1, 86_400)] + public int MaxActiveSecondsPerInterval { get; init; } = 1800; +} + +/// Cross-field validation that data annotations cannot express. +public sealed class ReadingOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, ReadingOptions options) + { + List? failures = null; + if (options.MaxCreditWpm > options.HardCutoffWpm) + { + (failures ??= []).Add( + $"{nameof(ReadingOptions.MaxCreditWpm)} ({options.MaxCreditWpm}) must not exceed {nameof(ReadingOptions.HardCutoffWpm)} ({options.HardCutoffWpm})."); + } + + if (options.PeriodicFlushActiveSeconds > options.MaxActiveSecondsPerInterval) + { + (failures ??= []).Add( + $"{nameof(ReadingOptions.PeriodicFlushActiveSeconds)} must not exceed {nameof(ReadingOptions.MaxActiveSecondsPerInterval)}."); + } + + return failures is null ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); + } } diff --git a/EssentialCSharp.Web/Services/ReadingProfileService.cs b/EssentialCSharp.Web/Services/ReadingProfileService.cs index 3efee7bb..5f2a7b3c 100644 --- a/EssentialCSharp.Web/Services/ReadingProfileService.cs +++ b/EssentialCSharp.Web/Services/ReadingProfileService.cs @@ -16,6 +16,12 @@ public sealed partial class ReadingProfileService( [LoggerMessage(Level = LogLevel.Debug, Message = "Ignored interval for {PageKey}: {Words} words in {Seconds}s is not credited")] private static partial void LogIntervalIgnored(ILogger logger, string pageKey, int words, int seconds); + /// + /// Not idempotent: if a client retries a request whose response was lost, the same intervals are credited + /// twice. This is accepted (no idempotency store); the effect is a small skew in the derived WPM. + /// The reference WPM used for the slow-outlier rule is read once before the batch is written, so concurrent + /// requests from the same user may evaluate against a slightly stale reference. This is also accepted. + /// public async Task RecordSessionAsync( string userId, IReadOnlyList intervals, CancellationToken cancellationToken) { @@ -81,39 +87,49 @@ await context.UserReadingProfiles .ExecuteDeleteAsync(cancellationToken); /// - /// Atomically creates the profile row or adds the deltas to it in a single statement, - /// so concurrent requests for the same user never lose an increment or violate the key. + /// Adds the deltas to the user's profile, creating the row on first use, without a provider-specific upsert: + /// try an atomic UPDATE ... SET x = x + delta; if no row exists insert one; if a concurrent request + /// inserted it first (duplicate key), retry the update once. Concurrent requests therefore never lose an increment. /// private async Task AddToProfileAsync( string userId, long words, long seconds, DateTime now, CancellationToken cancellationToken) { - if (context.Database.IsSqlServer()) + if (await TryAddToExistingAsync(userId, words, seconds, now, cancellationToken) > 0) { - await context.Database.ExecuteSqlAsync( - $""" - MERGE UserReadingProfiles WITH (HOLDLOCK) AS target - USING (SELECT {userId} AS UserId) AS source ON target.UserId = source.UserId - WHEN MATCHED THEN UPDATE SET - TotalWordsRead = target.TotalWordsRead + {words}, - TotalActiveSeconds = target.TotalActiveSeconds + {seconds}, - UpdatedAtUtc = {now} - WHEN NOT MATCHED THEN INSERT (UserId, TotalWordsRead, TotalActiveSeconds, UpdatedAtUtc) - VALUES ({userId}, {words}, {seconds}, {now}); - """, - cancellationToken); + return; } - else // SQLite (used by the integration tests) + + var created = new UserReadingProfile { - await context.Database.ExecuteSqlAsync( - $""" - INSERT INTO UserReadingProfiles (UserId, TotalWordsRead, TotalActiveSeconds, UpdatedAtUtc) - VALUES ({userId}, {words}, {seconds}, {now}) - ON CONFLICT (UserId) DO UPDATE SET - TotalWordsRead = TotalWordsRead + excluded.TotalWordsRead, - TotalActiveSeconds = TotalActiveSeconds + excluded.TotalActiveSeconds, - UpdatedAtUtc = excluded.UpdatedAtUtc - """, - cancellationToken); + UserId = userId, + TotalWordsRead = words, + TotalActiveSeconds = seconds, + UpdatedAtUtc = now, + }; + context.UserReadingProfiles.Add(created); + try + { + await context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + // Most likely a concurrent request created the row between our UPDATE and INSERT. + context.Entry(created).State = EntityState.Detached; + if (await TryAddToExistingAsync(userId, words, seconds, now, cancellationToken) == 0) + { + throw; // Not a duplicate key (for example the user no longer exists). + } } } + + private Task TryAddToExistingAsync( + string userId, long words, long seconds, DateTime now, CancellationToken cancellationToken) => + context.UserReadingProfiles + .Where(p => p.UserId == userId) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(p => p.TotalWordsRead, p => p.TotalWordsRead + words) + .SetProperty(p => p.TotalActiveSeconds, p => p.TotalActiveSeconds + seconds) + .SetProperty(p => p.UpdatedAtUtc, now), + cancellationToken); } diff --git a/EssentialCSharp.Web/Services/ReadingSpeedPolicy.cs b/EssentialCSharp.Web/Services/ReadingSpeedPolicy.cs index 03b1c5e9..6a18309e 100644 --- a/EssentialCSharp.Web/Services/ReadingSpeedPolicy.cs +++ b/EssentialCSharp.Web/Services/ReadingSpeedPolicy.cs @@ -34,8 +34,9 @@ public static class ReadingSpeedPolicy double slowThreshold = options.SlowOutlierFactor * referenceWpm; if (referenceWpm > 0 && intervalWpm < slowThreshold) { - int clampedSeconds = (int)Math.Max(1, Math.Ceiling(words / slowThreshold * 60.0)); - return new ReadingCredit(words, Math.Min(seconds, clampedSeconds)); + // words >= 1 and slowThreshold > 0 here, so the ceiling is at least 1. + double clampedSeconds = Math.Ceiling(words / slowThreshold * 60.0); + return new ReadingCredit(words, (int)Math.Min(seconds, clampedSeconds)); } return new ReadingCredit(words, seconds); diff --git a/EssentialCSharp.Web/ViewComponents/ReadingContextViewComponent.cs b/EssentialCSharp.Web/ViewComponents/ReadingContextViewComponent.cs index a1baec1c..357da114 100644 --- a/EssentialCSharp.Web/ViewComponents/ReadingContextViewComponent.cs +++ b/EssentialCSharp.Web/ViewComponents/ReadingContextViewComponent.cs @@ -1,7 +1,9 @@ +using System.Security.Claims; using EssentialCSharp.Web.Areas.Identity.Data; using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; namespace EssentialCSharp.Web.ViewComponents; @@ -9,30 +11,69 @@ namespace EssentialCSharp.Web.ViewComponents; /// Emits window.READING_CONTEXT for the reading-time tracker. Renders nothing on pages that /// are not book content (no ViewBag.CurrentPageKey), such as Identity pages and errors. /// -public class ReadingContextViewComponent( +public partial class ReadingContextViewComponent( IWordCountService wordCountService, - SignInManager signInManager) : ViewComponent + IReadingProfileService readingProfileService, + SignInManager signInManager, + IOptions options, + ILogger logger) : ViewComponent { - public IViewComponentResult Invoke() + [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load the reading profile for the reading context; continuing without a personal WPM")] + private static partial void LogProfileUnavailable(ILogger logger, Exception exception); + + public async Task InvokeAsync() { if (ViewData["CurrentPageKey"] is not string { Length: > 0 } pageKey) { return Content(string.Empty); } + bool isAuthenticated = signInManager.IsSignedIn(UserClaimsPrincipal); + int? wpm = isAuthenticated ? await GetWpmAsync() : null; + ReadingOptions reading = options.Value; + PageWordStats? stats = wordCountService.GetPageStats(pageKey); return View(new ReadingContextViewModel( - IsAuthenticated: signInManager.IsSignedIn(UserClaimsPrincipal), + IsAuthenticated: isAuthenticated, PageKey: pageKey, PageWords: stats?.PageWords ?? 0, ChapterWords: stats?.ChapterWords ?? 0, WordsBeforePage: stats?.WordsBeforePage ?? 0, ChapterStartWords: stats?.ChapterStartWords ?? 0, - BookWords: stats?.BookWords ?? wordCountService.GetBookWordCount())); + BookWords: stats?.BookWords ?? wordCountService.GetBookWordCount(), + Wpm: wpm, + DefaultWpm: reading.DefaultWpm, + MaxCreditWpm: reading.MaxCreditWpm, + IdleThresholdSeconds: reading.IdleThresholdSeconds, + PeriodicFlushActiveSeconds: reading.PeriodicFlushActiveSeconds)); + } + + /// The signed-in reader's derived WPM rounded to whole words per minute, or null when unknown. + private async Task GetWpmAsync() + { + if (UserClaimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier) is not { } userId) + { + return null; + } + + try + { + ReadingProfileSummary profile = await readingProfileService.GetProfileAsync(userId, HttpContext.RequestAborted); + return profile.Wpm is { } value ? (int)Math.Round(value) : null; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // A reading-profile outage must not break rendering of book pages. + LogProfileUnavailable(logger, ex); + return null; + } } } -/// Serialized (camelCase) as window.READING_CONTEXT; property names are a contract with the client. +/// +/// Serialized (camelCase) as window.READING_CONTEXT; property names are a contract with the client. +/// is null for anonymous readers or readers with no recorded reading time. +/// public record ReadingContextViewModel( bool IsAuthenticated, string PageKey, @@ -40,4 +81,9 @@ public record ReadingContextViewModel( int ChapterWords, int WordsBeforePage, int ChapterStartWords, - int BookWords); + int BookWords, + int? Wpm, + int DefaultWpm, + int MaxCreditWpm, + int IdleThresholdSeconds, + int PeriodicFlushActiveSeconds); From 988871a9f7ef5e774169c71eaf814b688ec3cee2 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Thu, 5 Nov 2026 22:09:27 -0800 Subject: [PATCH 16/38] test: record antiforgery vs validation failure contract for POST /api/reading/session --- .../ReadingAntiforgeryContractTests.cs | 115 ++++++++++++++++++ .../ReadingControllerTests.cs | 2 +- 2 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 EssentialCSharp.Web.Tests/ReadingAntiforgeryContractTests.cs diff --git a/EssentialCSharp.Web.Tests/ReadingAntiforgeryContractTests.cs b/EssentialCSharp.Web.Tests/ReadingAntiforgeryContractTests.cs new file mode 100644 index 00000000..bd5e4902 --- /dev/null +++ b/EssentialCSharp.Web.Tests/ReadingAntiforgeryContractTests.cs @@ -0,0 +1,115 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text; +using EssentialCSharp.Web.Areas.Identity.Data; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace EssentialCSharp.Web.Tests; + +/// +/// Evidence for the client contract: what POST /api/reading/session returns for a missing or invalid antiforgery +/// token versus a body that fails validation, so the client can tell "stop tracking" from "drop this interval". +/// +public class ReadingAntiforgeryContractTests : IntegrationTestBase +{ + private sealed record Observed(HttpStatusCode Status, string? ContentType, string Body) + { + public override string ToString() => $"{(int)Status} content-type={ContentType ?? ""} body={(Body.Length == 0 ? "" : Body)}"; + } + + private const string ValidBody = """{"intervals":[{"pageKey":"x","wordsRead":10,"activeSeconds":10}]}"""; + private const string InvalidBody = """{"intervals":[]}"""; + + [Test] + public async Task AntiforgeryFailure_IsDistinguishableFromValidationFailure() + { + string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-csrf"); + (string cookieHeader, string token, string headerName) = await CreateCredentialsAsync(userId); + + Observed missing = await PostAsync(cookieHeader, headerName, token: null, ValidBody); + Observed invalid = await PostAsync(cookieHeader, headerName, token: "not-a-valid-token", ValidBody); + Observed validationFailure = await PostAsync(cookieHeader, headerName, token, InvalidBody); + + Console.WriteLine($"missing token : {missing}"); + Console.WriteLine($"invalid token : {invalid}"); + Console.WriteLine($"valid token, invalid body : {validationFailure}"); + + // Observed contract: all three are 400 application/problem+json. A validation failure carries an "errors" + // object (and the title "One or more validation errors occurred."); an antiforgery failure is a bare + // problem document with no "errors" member. A missing token and an invalid token look the same. + using (Assert.Multiple()) + { + await Assert.That(validationFailure.Status).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(HasErrorsMember(validationFailure)).IsTrue(); + + foreach (Observed antiforgeryFailure in new[] { missing, invalid }) + { + await Assert.That(antiforgeryFailure.Status).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(antiforgeryFailure.ContentType).StartsWith("application/problem+json"); + await Assert.That(HasErrorsMember(antiforgeryFailure)).IsFalse(); + } + } + } + + private static bool HasErrorsMember(Observed response) + { + using JsonDocument document = JsonDocument.Parse(response.Body); + return document.RootElement.TryGetProperty("errors", out _); + } + + private async Task PostAsync(string cookieHeader, string headerName, string? token, string body) + { + using HttpClient client = McpTestHelper.CreateClient(Factory); + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/reading/session") + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + request.Headers.Add("Cookie", cookieHeader); + if (token is not null) + { + request.Headers.Add(headerName, token); + } + + using HttpResponseMessage response = await client.SendAsync(request); + MediaTypeHeaderValue? contentType = response.Content.Headers.ContentType; + return new Observed(response.StatusCode, contentType?.ToString(), await response.Content.ReadAsStringAsync()); + } + + private async Task<(string CookieHeader, string Token, string HeaderName)> CreateCredentialsAsync(string userId) + { + (string cookieName, string cookieValue) = + await McpTestHelper.CreateIdentityApplicationCookieAsync(Factory, userId); + + (string antiforgeryCookie, string requestToken, string headerName) = await InServiceScopeAsync(async services => + { + EssentialCSharpWebUser user = await services.GetRequiredService>() + .FindByIdAsync(userId) + ?? throw new InvalidOperationException($"User {userId} does not exist."); + var context = new DefaultHttpContext + { + RequestServices = services, + User = await services.GetRequiredService>() + .CreateAsync(user) + }; + + AntiforgeryTokenSet tokens = services.GetRequiredService().GetAndStoreTokens(context); + string antiforgeryCookieName = services.GetRequiredService>().Value.Cookie.Name + ?? throw new InvalidOperationException("The antiforgery cookie name is not configured."); + string cookie = context.Response.Headers.SetCookie + .Select(value => value?.Split(';', 2)[0]) + .OfType() + .Single(value => value.StartsWith(antiforgeryCookieName + "=", StringComparison.Ordinal)); + return ( + cookie, + tokens.RequestToken ?? throw new InvalidOperationException("No antiforgery request token."), + tokens.HeaderName ?? "RequestVerificationToken"); + }); + + return ($"{cookieName}={cookieValue}; {antiforgeryCookie}", requestToken, headerName); + } +} diff --git a/EssentialCSharp.Web.Tests/ReadingControllerTests.cs b/EssentialCSharp.Web.Tests/ReadingControllerTests.cs index 5e25a5d1..b209e128 100644 --- a/EssentialCSharp.Web.Tests/ReadingControllerTests.cs +++ b/EssentialCSharp.Web.Tests/ReadingControllerTests.cs @@ -172,7 +172,7 @@ public async Task PostSession_VerySlowInterval_CountsForLessThanItsWallClockTime string userId = await CreateUserWithBaselineProfileAsync(); using HttpClient client = await CreateAuthenticatedClientAsync(userId); int words = Math.Min(page.WordCount, 50); - int wallClockSeconds = 3600; + int wallClockSeconds = Options.MaxActiveSecondsPerInterval; using HttpResponseMessage response = await PostSessionAsync(client, Interval(page.Key, words, wallClockSeconds)); From 81e647c17044bf2ceee6673a74ddc87d2a1b1af3 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Thu, 5 Nov 2026 22:51:38 -0800 Subject: [PATCH 17/38] fix(client): single-source reading constants, retry queue, credit carry-forward, reset broadcast, a11y, Vitest --- .github/workflows/Build-Test-And-Deploy.yml | 4 + .../Pages/Account/Manage/Index.cshtml | 2 +- EssentialCSharp.Web/package-lock.json | 392 ++++++++++++++++-- EssentialCSharp.Web/package.json | 7 +- .../src/components/ReadingTimeRemaining.vue | 2 - .../src/composables/useActivityState.js | 14 +- .../src/composables/useReadingTracker.js | 174 +++++--- EssentialCSharp.Web/src/reading-reset.js | 38 +- .../src/services/readingApi.js | 68 ++- .../src/utils/readingConstants.js | 27 +- .../src/utils/readingContext.js | 20 +- .../src/utils/readingProgress.js | 52 +-- EssentialCSharp.Web/vite.config.js | 4 + 13 files changed, 635 insertions(+), 169 deletions(-) diff --git a/.github/workflows/Build-Test-And-Deploy.yml b/.github/workflows/Build-Test-And-Deploy.yml index cb441a0b..a6585225 100644 --- a/.github/workflows/Build-Test-And-Deploy.yml +++ b/.github/workflows/Build-Test-And-Deploy.yml @@ -56,6 +56,10 @@ jobs: working-directory: EssentialCSharp.Web run: npm run build + - name: Test frontend + working-directory: EssentialCSharp.Web + run: npm test + build-and-test: name: Build and test (Ubuntu) if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml index a131dbbb..3b348125 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml @@ -46,7 +46,7 @@ ViewData["ActivePage"] = ManageNavPages.Index; data-confirm="Reset your reading speed? This cannot be undone."> Reset reading speed - +
diff --git a/EssentialCSharp.Web/package-lock.json b/EssentialCSharp.Web/package-lock.json index 0d5f0b8a..a648b26d 100644 --- a/EssentialCSharp.Web/package-lock.json +++ b/EssentialCSharp.Web/package-lock.json @@ -18,7 +18,9 @@ }, "devDependencies": { "@vitejs/plugin-vue": "6.0.10", - "vite": "8.3.5" + "happy-dom": "21.0.4", + "vite": "8.3.5", + "vitest": "5.1.0" }, "engines": { "node": ">=22.0.0" @@ -70,12 +72,31 @@ "node": ">=6.9.0" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@mdi/font": { "version": "7.4.47", "resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz", @@ -202,9 +223,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -222,9 +240,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -242,9 +257,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -262,9 +274,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -282,9 +291,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -302,9 +308,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -372,12 +375,58 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true + }, + "node_modules/@types/node": { + "version": "26.6.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", + "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", + "dev": true, + "dependencies": { + "undici-types": "~8.9.0" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "optional": true }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-vue": { "version": "6.0.10", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.10.tgz", @@ -395,6 +444,60 @@ "vue": "^3.2.25 || ^3.6.0-rc.10" } }, + "node_modules/@vitest/mocker": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.1.0.tgz", + "integrity": "sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.1.0.tgz", + "integrity": "sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==", + "dev": true, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.44", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.44.tgz", @@ -587,6 +690,36 @@ "@zxcvbn-ts/dictionary-compression": "^3.0.1" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -624,12 +757,27 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fastest-levenshtein": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", @@ -670,6 +818,24 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/happy-dom": { + "version": "21.0.4", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-21.0.4.tgz", + "integrity": "sha512-tFm+GaLBgo6o0awHnvHaNoraVo27qJ+2tZEtZ+cIa08hXbeitrxAJajLI05hsUy/2/YTKcUjQYzHe5mCcraLBw==", + "dev": true, + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/jquery": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jquery/-/jquery-4.0.0.tgz", @@ -819,9 +985,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -843,9 +1006,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -867,9 +1027,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -891,9 +1048,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -988,6 +1142,19 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1077,6 +1244,30 @@ "node": ">=0.10.0" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true + }, + "node_modules/tinybench": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.3.tgz", + "integrity": "sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==", + "dev": true, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1094,6 +1285,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true + }, "node_modules/vite": { "version": "8.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", @@ -1172,6 +1369,97 @@ } } }, + "node_modules/vitest": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.1.0.tgz", + "integrity": "sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==", + "dev": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.1.0", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.1.0", + "@vitest/browser-preview": "5.1.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.1.0", + "@vitest/coverage-v8": "5.1.0", + "@vitest/ui": "5.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/vue": { "version": "3.5.44", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.44.tgz", @@ -1222,6 +1510,48 @@ "optional": true } } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/ws": { + "version": "8.22.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz", + "integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/EssentialCSharp.Web/package.json b/EssentialCSharp.Web/package.json index 107fe10d..ccb798f3 100644 --- a/EssentialCSharp.Web/package.json +++ b/EssentialCSharp.Web/package.json @@ -7,7 +7,8 @@ }, "scripts": { "build": "vite build", - "dev": "vite" + "dev": "vite", + "test": "vitest run" }, "dependencies": { "@mdi/font": "7.4.47", @@ -22,6 +23,8 @@ }, "devDependencies": { "@vitejs/plugin-vue": "6.0.10", - "vite": "8.3.5" + "happy-dom": "21.0.4", + "vite": "8.3.5", + "vitest": "5.1.0" } } diff --git a/EssentialCSharp.Web/src/components/ReadingTimeRemaining.vue b/EssentialCSharp.Web/src/components/ReadingTimeRemaining.vue index e5d232ed..63e466da 100644 --- a/EssentialCSharp.Web/src/components/ReadingTimeRemaining.vue +++ b/EssentialCSharp.Web/src/components/ReadingTimeRemaining.vue @@ -41,8 +41,6 @@ const spokenText = computed(() => {
{{ spokenText }}