From 6febeb53b9c3277e518633ff7ac2f938fa71b1bc Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 22 Mar 2026 20:03:52 +0200 Subject: [PATCH 01/18] WIP: sliding-window rate limiter + security headers (checkpoint) --- Areas/Public/Controllers/TilesController.cs | 2 + Services/RateLimitHelper.cs | 59 ++++++++-- .../Controllers/TilesControllerTests.cs | 3 + .../Services/RateLimitHelperTests.cs | 101 ++++++++++++++++++ 4 files changed, 154 insertions(+), 11 deletions(-) diff --git a/Areas/Public/Controllers/TilesController.cs b/Areas/Public/Controllers/TilesController.cs index 5933716f..bc41742c 100644 --- a/Areas/Public/Controllers/TilesController.cs +++ b/Areas/Public/Controllers/TilesController.cs @@ -110,6 +110,8 @@ public async Task GetTile(int z, int x, int y) // Set browser cache headers. Tiles are stable and rarely change; // 1-day browser caching eliminates redundant requests. Response.Headers["Cache-Control"] = "public, max-age=86400"; + // Prevent browsers from MIME-sniffing PNG responses as a different content type. + Response.Headers["X-Content-Type-Options"] = "nosniff"; // Return the tile data with the appropriate content type. return File(tileData, "image/png"); diff --git a/Services/RateLimitHelper.cs b/Services/RateLimitHelper.cs index 053f5746..b9c17ae1 100644 --- a/Services/RateLimitHelper.cs +++ b/Services/RateLimitHelper.cs @@ -5,21 +5,32 @@ namespace Wayfarer.Services; /// -/// Shared rate limiting utility using a fixed-window approach with 1-minute expiration. -/// Thread-safe: uses atomic operations to prevent race conditions. +/// Shared rate limiting utility using a sliding-window counter approximation with 1-minute windows. +/// Prevents boundary-batching attacks where a burst at the end of one window plus the start of the +/// next could double the effective limit. Thread-safe: uses atomic operations to prevent race conditions. /// Used by and /// . /// public static class RateLimitHelper { /// - /// Tracks the request count and window expiration for rate limiting. + /// The duration of one rate-limit window in ticks (1 minute). + /// + internal static readonly long WindowTicks = TimeSpan.FromMinutes(1).Ticks; + + /// + /// Tracks the request count and window expiration for rate limiting using a sliding-window + /// counter approximation. Maintains the previous window's count so that requests near a + /// boundary are weighted, preventing the boundary-batching exploit where an attacker sends + /// the full limit at :59s and again at :00s to achieve 2× the intended rate. /// Uses atomic operations (Interlocked) for thread safety. /// public sealed class RateLimitEntry { private int _count; + private int _prevCount; private long _expirationTicks; + private long _windowStartTicks; /// /// Initializes a new rate limit entry with the given expiration. @@ -28,17 +39,21 @@ public sealed class RateLimitEntry public RateLimitEntry(long expirationTicks) { _count = 0; + _prevCount = 0; _expirationTicks = expirationTicks; + _windowStartTicks = expirationTicks - WindowTicks; } /// - /// Atomically increments the counter and returns the new count. - /// If the window has expired, resets the counter and updates expiration using - /// compare-and-swap to avoid TOCTOU race conditions. + /// Atomically increments the counter and returns the weighted sliding-window count. + /// If the window has expired, rotates: copies current count to previous, resets current, + /// and updates expiration using compare-and-swap to avoid TOCTOU race conditions. + /// The returned count is: prevCount * (1 - elapsed/windowSize) + currentCount, + /// which smoothly decays the previous window's contribution over the new window. /// /// The current tick count. /// The new expiration tick count if a reset occurs. - /// The incremented request count for the current window. + /// The weighted sliding-window request count. public int IncrementAndGet(long currentTicks, long newExpirationTicks) { var currentExpiration = Interlocked.Read(ref _expirationTicks); @@ -46,24 +61,46 @@ public int IncrementAndGet(long currentTicks, long newExpirationTicks) { if (Interlocked.CompareExchange(ref _expirationTicks, newExpirationTicks, currentExpiration) == currentExpiration) { + // Won the CAS — rotate window: current becomes previous, reset current. + Interlocked.Exchange(ref _prevCount, Volatile.Read(ref _count)); Interlocked.Exchange(ref _count, 0); + Interlocked.Exchange(ref _windowStartTicks, currentExpiration); } } - return Interlocked.Increment(ref _count); + var currentCount = Interlocked.Increment(ref _count); + + // Compute sliding-window weighted count. + // elapsed = how far into the current window we are (0.0 to 1.0). + // weight = fraction of previous window still relevant (1.0 at start, 0.0 at end). + var windowStart = Interlocked.Read(ref _windowStartTicks); + var windowEnd = Interlocked.Read(ref _expirationTicks); + var windowSize = windowEnd - windowStart; + if (windowSize <= 0) windowSize = WindowTicks; + var elapsed = currentTicks - windowStart; + if (elapsed < 0) elapsed = 0; + if (elapsed > windowSize) elapsed = windowSize; + + var prevWeight = 1.0 - ((double)elapsed / windowSize); + var prev = Volatile.Read(ref _prevCount); + var weighted = (int)(prev * prevWeight) + currentCount; + + return weighted; } /// /// Returns true if this entry's window has expired. + /// Uses a 2-window horizon: an entry is considered expired only after 2 full windows + /// have passed, since the sliding-window algorithm needs the previous window's count. /// /// The current tick count. /// True if expired, false otherwise. - public bool IsExpired(long currentTicks) => currentTicks > Interlocked.Read(ref _expirationTicks); + public bool IsExpired(long currentTicks) => currentTicks > Interlocked.Read(ref _expirationTicks) + WindowTicks; } /// - /// Checks if the given IP has exceeded the rate limit and atomically increments the counter. - /// Uses a fixed window approach with 1-minute expiration. + /// Checks if the given client key has exceeded the rate limit and atomically increments the counter. + /// Uses a sliding-window counter approximation with 1-minute windows to prevent boundary batching. /// /// The concurrent dictionary tracking rate limit entries per IP. /// The client IP address to check. diff --git a/tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs b/tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs index 035fb3cc..a86d2936 100644 --- a/tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs +++ b/tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs @@ -102,6 +102,9 @@ public async Task GetTile_ReturnsPng_WhenCached() var file = Assert.IsType(result); Assert.Equal("image/png", file.ContentType); Assert.Equal(new byte[] { 1, 2, 3 }, file.FileContents); + + // Verify security headers are set on tile responses. + Assert.Equal("nosniff", controller.Response.Headers["X-Content-Type-Options"].ToString()); } finally { diff --git a/tests/Wayfarer.Tests/Services/RateLimitHelperTests.cs b/tests/Wayfarer.Tests/Services/RateLimitHelperTests.cs index 0ee9b392..3e69b8dd 100644 --- a/tests/Wayfarer.Tests/Services/RateLimitHelperTests.cs +++ b/tests/Wayfarer.Tests/Services/RateLimitHelperTests.cs @@ -87,4 +87,105 @@ public void RateLimitEntry_ExpiredWindow_ResetsCounter() Assert.Equal(1, count); } + + /// + /// Verifies that the sliding-window algorithm prevents boundary-batching attacks. + /// In a fixed-window scheme, sending N requests at the end of window 1 and N at the start + /// of window 2 would allow 2N requests in ~2 seconds. The sliding window should block this. + /// + [Fact] + public void SlidingWindow_PreventsBoundaryBatching() + { + var cache = new ConcurrentDictionary(); + var ip = "10.0.0.50"; + const int limit = 10; + + // Simulate: send 10 requests just before window boundary (filling the window). + for (int i = 0; i < limit; i++) + { + RateLimitHelper.IsRateLimitExceeded(cache, ip, limit); + } + + // Manually rotate the window by constructing a scenario at the very start of a new window. + // The previous window had 10 requests; the new window just started so prevWeight ≈ 1.0. + // Under a fixed-window scheme, the counter would reset and allow another 10 immediately. + // Under sliding-window, the weighted count includes the previous window's contribution. + var entry = cache[ip]; + var now = DateTime.UtcNow.Ticks; + // Force a window rotation by calling with a time past the current expiration. + var farFuture = now + TimeSpan.FromMinutes(2).Ticks; + var newExpiration = farFuture + TimeSpan.FromMinutes(1).Ticks; + entry.IncrementAndGet(farFuture, newExpiration); + + // Now simulate being just 1 second into the new window (prevWeight ≈ 0.98). + // The previous window had ~10 requests, so weighted ≈ 10 * 0.98 + currentCount. + var windowStart = farFuture; // approximately + var oneSecondIn = windowStart + TimeSpan.FromSeconds(1).Ticks; + var newExp2 = oneSecondIn + TimeSpan.FromMinutes(1).Ticks; + + // Even the first request in the new window should see an elevated weighted count + // due to the previous window's contribution, so a new burst should be blocked sooner. + int exceededCount = 0; + for (int i = 0; i < limit; i++) + { + if (RateLimitHelper.IsRateLimitExceeded(cache, ip, limit)) + { + exceededCount++; + } + } + + // With sliding window, most of these should be blocked because the previous window's + // 10 requests are still weighted in. Under fixed-window, none would be blocked. + Assert.True(exceededCount > 0, "Sliding window should block requests near the boundary due to previous window's weight"); + } + + /// + /// Verifies that after a full window has elapsed with no activity, the previous window's + /// weight fully decays and the rate limiter allows the full limit again. + /// + [Fact] + public void SlidingWindow_FullDecay_AllowsFullLimitAfterQuietPeriod() + { + var cache = new ConcurrentDictionary(); + var ip = "10.0.0.51"; + const int limit = 5; + + // Fill the first window completely. + for (int i = 0; i < limit; i++) + { + RateLimitHelper.IsRateLimitExceeded(cache, ip, limit); + } + Assert.True(RateLimitHelper.IsRateLimitExceeded(cache, ip, limit), "Should exceed after filling window"); + + // Wait for two full windows to pass (previous window's weight is fully decayed). + // We simulate this by constructing a new entry that appears to have an old expiration. + var entry = cache[ip]; + var now = DateTime.UtcNow.Ticks; + var farFuture = now + TimeSpan.FromMinutes(3).Ticks; + var newExpiration = farFuture + TimeSpan.FromMinutes(1).Ticks; + + // First call in the far future rotates the window. + entry.IncrementAndGet(farFuture, newExpiration); + + // After a quiet period, another rotation zeroes the previous window's count too. + var evenFarther = farFuture + TimeSpan.FromMinutes(2).Ticks; + var newExp2 = evenFarther + TimeSpan.FromMinutes(1).Ticks; + entry.IncrementAndGet(evenFarther, newExp2); + + // Now the previous window count should be 1 (from the rotation call), and we're at + // the very end of the new window so prevWeight ≈ 0. We should be able to make ~limit requests. + var nearEnd = newExp2 - TimeSpan.FromSeconds(1).Ticks; + var finalExp = nearEnd + TimeSpan.FromMinutes(1).Ticks; + int allowed = 0; + for (int i = 0; i < limit; i++) + { + if (!RateLimitHelper.IsRateLimitExceeded(cache, ip, limit)) + { + allowed++; + } + } + + // After full decay, most (or all) of the limit should be available again. + Assert.True(allowed >= limit - 2, $"Expected at least {limit - 2} allowed after full decay, got {allowed}"); + } } From d18060a2165614ad82b25663cdb8bf6e3349875b Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 22 Mar 2026 20:06:43 +0200 Subject: [PATCH 02/18] WIP: authenticated user rate limiting + migration (checkpoint) --- Areas/Public/Controllers/TilesController.cs | 34 +- ..._AddAuthenticatedTileRateLimit.Designer.cs | 1603 +++++++++++++++++ ...322180500_AddAuthenticatedTileRateLimit.cs | 29 + .../ApplicationDbContextModelSnapshot.cs | 3 + Models/ApplicationSettings.cs | 16 +- .../Controllers/TilesControllerTests.cs | 93 +- 6 files changed, 1767 insertions(+), 11 deletions(-) create mode 100644 Migrations/20260322180500_AddAuthenticatedTileRateLimit.Designer.cs create mode 100644 Migrations/20260322180500_AddAuthenticatedTileRateLimit.cs diff --git a/Areas/Public/Controllers/TilesController.cs b/Areas/Public/Controllers/TilesController.cs index bc41742c..45ac1f9f 100644 --- a/Areas/Public/Controllers/TilesController.cs +++ b/Areas/Public/Controllers/TilesController.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Security.Claims; using Microsoft.AspNetCore.Mvc; using Wayfarer.Parsers; using Wayfarer.Services; @@ -32,6 +33,13 @@ public class TilesController : Controller /// private static readonly ConcurrentDictionary RateLimitCache = new(); + /// + /// Thread-safe dictionary for rate limiting authenticated tile requests by user ID. + /// Separate from anonymous rate limiting to apply different (higher) limits for trusted users + /// while still preventing abuse from compromised accounts. + /// + private static readonly ConcurrentDictionary AuthRateLimitCache = new(); + private readonly ILogger _logger; private readonly TileCacheService _tileCacheService; private readonly IApplicationSettingsService _settingsService; @@ -77,15 +85,27 @@ public async Task GetTile(int z, int x, int y) // Resolve the tile provider template from settings or presets. var settings = _settingsService.GetSettings(); - // Rate limit anonymous requests to prevent tile scraping abuse. - // Authenticated users (logged-in) are never rate limited. - if (User.Identity?.IsAuthenticated != true && settings.TileRateLimitEnabled) + // Rate limit tile requests to prevent abuse. + // Anonymous users are limited by IP; authenticated users by user ID at a higher threshold. + if (settings.TileRateLimitEnabled) { - var clientIp = GetClientIpAddress(); - if (RateLimitHelper.IsRateLimitExceeded(RateLimitCache, clientIp, settings.TileRateLimitPerMinute)) + if (User.Identity?.IsAuthenticated == true) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown"; + if (RateLimitHelper.IsRateLimitExceeded(AuthRateLimitCache, userId, settings.TileRateLimitAuthenticatedPerMinute)) + { + _logger.LogWarning("Tile rate limit exceeded for authenticated user: {UserId}", userId); + return StatusCode(429, "Too many requests. Please try again later."); + } + } + else { - _logger.LogWarning("Tile rate limit exceeded for IP: {ClientIp}", clientIp); - return StatusCode(429, "Too many requests. Please try again later."); + var clientIp = GetClientIpAddress(); + if (RateLimitHelper.IsRateLimitExceeded(RateLimitCache, clientIp, settings.TileRateLimitPerMinute)) + { + _logger.LogWarning("Tile rate limit exceeded for IP: {ClientIp}", clientIp); + return StatusCode(429, "Too many requests. Please try again later."); + } } } var preset = TileProviderCatalog.FindPreset(settings.TileProviderKey); diff --git a/Migrations/20260322180500_AddAuthenticatedTileRateLimit.Designer.cs b/Migrations/20260322180500_AddAuthenticatedTileRateLimit.Designer.cs new file mode 100644 index 00000000..4521609a --- /dev/null +++ b/Migrations/20260322180500_AddAuthenticatedTileRateLimit.Designer.cs @@ -0,0 +1,1603 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Wayfarer.Models; + +#nullable disable + +namespace Wayfarer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260322180500_AddAuthenticatedTileRateLimit")] + partial class AddAuthenticatedTileRateLimit + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext"); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ApplicationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImageCacheExpiryDays") + .HasColumnType("integer"); + + b.Property("IsRegistrationOpen") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LocationAccuracyThresholdMeters") + .HasColumnType("integer"); + + b.Property("LocationDistanceThresholdMeters") + .HasColumnType("integer"); + + b.Property("LocationTimeThresholdMinutes") + .HasColumnType("integer"); + + b.Property("MaxCacheImageSizeInMB") + .HasColumnType("integer"); + + b.Property("MaxCacheTileSizeInMB") + .HasColumnType("integer"); + + b.Property("MaxProxyImageDownloadMB") + .HasColumnType("integer"); + + b.Property("ProxyImageRateLimitEnabled") + .HasColumnType("boolean"); + + b.Property("ProxyImageRateLimitPerMinute") + .HasColumnType("integer"); + + b.Property("TileProviderApiKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TileProviderAttribution") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("TileProviderKey") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TileProviderUrlTemplate") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TileRateLimitAuthenticatedPerMinute") + .HasColumnType("integer"); + + b.Property("TileRateLimitEnabled") + .HasColumnType("boolean"); + + b.Property("TileRateLimitPerMinute") + .HasColumnType("integer"); + + b.Property("UploadSizeLimitMB") + .HasColumnType("integer"); + + b.Property("VisitNotificationCooldownHours") + .HasColumnType("integer"); + + b.Property("VisitedAccuracyMultiplier") + .HasColumnType("double precision"); + + b.Property("VisitedAccuracyRejectMeters") + .HasColumnType("integer"); + + b.Property("VisitedMaxRadiusMeters") + .HasColumnType("integer"); + + b.Property("VisitedMaxSearchRadiusMeters") + .HasColumnType("integer"); + + b.Property("VisitedMinRadiusMeters") + .HasColumnType("integer"); + + b.Property("VisitedPlaceNotesSnapshotMaxHtmlChars") + .HasColumnType("integer"); + + b.Property("VisitedRequiredHits") + .HasColumnType("integer"); + + b.Property("VisitedSuggestionMaxRadiusMultiplier") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ApplicationSettings"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TripTags", b => + { + b.Property("TripId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.HasKey("TripId", "TagId"); + + b.HasIndex("TagId"); + + b.HasIndex("TripId"); + + b.ToTable("TripTags", (string)null); + }); + + modelBuilder.Entity("Wayfarer.Models.ActivityType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("ActivityTypes"); + }); + + modelBuilder.Entity("Wayfarer.Models.ApiToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Token") + .HasColumnType("text"); + + b.Property("TokenHash") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Name", "UserId") + .IsUnique() + .HasDatabaseName("IX_ApiToken_Name_UserId"); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("Wayfarer.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsProtected") + .HasColumnType("boolean"); + + b.Property("IsTimelinePublic") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PublicTimelineTimeThreshold") + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Wayfarer.Models.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("FillHex") + .HasColumnType("text"); + + b.Property("Geometry") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RegionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RegionId"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("Wayfarer.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Wayfarer.Models.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GroupType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrgPeerVisibilityEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InviteeEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("InviteeUserId") + .HasColumnType("text"); + + b.Property("InviterUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("RespondedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("InviteeUserId"); + + b.HasIndex("InviterUserId"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("GroupId", "InviteeUserId") + .IsUnique() + .HasDatabaseName("IX_GroupInvitation_GroupId_InviteeUserId_Pending") + .HasFilter("\"Status\" = 'Pending' AND \"InviteeUserId\" IS NOT NULL"); + + b.ToTable("GroupInvitations"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LeftAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrgPeerVisibilityAccessDisabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("GroupId", "Status") + .HasDatabaseName("IX_GroupMember_GroupId_Status"); + + b.HasIndex("GroupId", "UserId") + .IsUnique(); + + b.ToTable("GroupMembers"); + }); + + modelBuilder.Entity("Wayfarer.Models.HiddenArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Area") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("HiddenAreas"); + }); + + modelBuilder.Entity("Wayfarer.Models.ImageCacheMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CacheKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastAccessed") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Size") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CacheKey") + .IsUnique() + .HasDatabaseName("IX_ImageCacheMetadata_CacheKey"); + + b.ToTable("ImageCacheMetadata"); + }); + + modelBuilder.Entity("Wayfarer.Models.JobHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastRunTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("JobHistories"); + }); + + modelBuilder.Entity("Wayfarer.Models.Location", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Accuracy") + .HasColumnType("double precision"); + + b.Property("ActivityTypeId") + .HasColumnType("integer"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("AddressNumber") + .HasColumnType("text"); + + b.Property("Altitude") + .HasColumnType("double precision"); + + b.Property("AppBuild") + .HasColumnType("text"); + + b.Property("AppVersion") + .HasColumnType("text"); + + b.Property("BatteryLevel") + .HasColumnType("integer"); + + b.Property("Bearing") + .HasColumnType("double precision"); + + b.Property("Coordinates") + .IsRequired() + .HasColumnType("geography(Point, 4326)"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("DeviceModel") + .HasColumnType("text"); + + b.Property("FullAddress") + .HasColumnType("text"); + + b.Property("IdempotencyKey") + .HasColumnType("uuid"); + + b.Property("IsCharging") + .HasColumnType("boolean"); + + b.Property("IsUserInvoked") + .HasColumnType("boolean"); + + b.Property("LocalTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("LocationType") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OsVersion") + .HasColumnType("text"); + + b.Property("Place") + .HasColumnType("text"); + + b.Property("PostCode") + .HasColumnType("text"); + + b.Property("Provider") + .HasColumnType("text"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("Source") + .HasColumnType("text"); + + b.Property("Speed") + .HasColumnType("double precision"); + + b.Property("StreetName") + .HasColumnType("text"); + + b.Property("TimeZoneId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ActivityTypeId"); + + b.HasIndex("Coordinates") + .HasDatabaseName("IX_Location_Coordinates"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Coordinates"), "GIST"); + + b.HasIndex("UserId", "IdempotencyKey") + .IsUnique() + .HasDatabaseName("IX_Location_UserId_IdempotencyKey"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationImport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileType") + .HasColumnType("integer"); + + b.Property("LastImportedRecord") + .HasColumnType("text"); + + b.Property("LastProcessedIndex") + .HasColumnType("integer"); + + b.Property("SkippedDuplicates") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalRecords") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("LocationImports"); + }); + + modelBuilder.Entity("Wayfarer.Models.Place", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IconName") + .HasColumnType("text"); + + b.Property("Location") + .HasColumnType("geography(Point,4326)"); + + b.Property("MarkerColor") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RegionId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RegionId"); + + b.ToTable("Places"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitCandidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsecutiveHits") + .HasColumnType("integer"); + + b.Property("FirstHitUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastHitUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PlaceId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("LastHitUtc") + .HasDatabaseName("IX_PlaceVisitCandidate_LastHitUtc"); + + b.HasIndex("PlaceId"); + + b.HasIndex("UserId", "PlaceId") + .IsUnique() + .HasDatabaseName("IX_PlaceVisitCandidate_UserId_PlaceId"); + + b.ToTable("PlaceVisitCandidates"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArrivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EndedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IconNameSnapshot") + .HasColumnType("text"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MarkerColorSnapshot") + .HasColumnType("text"); + + b.Property("NotesHtml") + .HasColumnType("text"); + + b.Property("PlaceId") + .HasColumnType("uuid"); + + b.Property("PlaceLocationSnapshot") + .HasColumnType("geography(Point,4326)"); + + b.Property("PlaceNameSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionNameSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("Source") + .HasColumnType("text"); + + b.Property("TripIdSnapshot") + .HasColumnType("uuid"); + + b.Property("TripNameSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ArrivedAtUtc") + .HasDatabaseName("IX_PlaceVisitEvent_ArrivedAtUtc"); + + b.HasIndex("PlaceId") + .HasDatabaseName("IX_PlaceVisitEvent_PlaceId"); + + b.HasIndex("UserId", "EndedAtUtc") + .HasDatabaseName("IX_PlaceVisitEvent_UserId_EndedAtUtc"); + + b.ToTable("PlaceVisitEvents"); + }); + + modelBuilder.Entity("Wayfarer.Models.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Center") + .HasColumnType("geography(Point,4326)"); + + b.Property("CoverImageUrl") + .HasColumnType("text"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("TripId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TripId"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Wayfarer.Models.Segment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("EstimatedDistanceKm") + .HasColumnType("double precision"); + + b.Property("EstimatedDuration") + .HasColumnType("interval"); + + b.Property("FromPlaceId") + .HasColumnType("uuid"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RouteGeometry") + .HasColumnType("geography(LineString,4326)"); + + b.Property("ToPlaceId") + .HasColumnType("uuid"); + + b.Property("TripId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("FromPlaceId"); + + b.HasIndex("ToPlaceId"); + + b.HasIndex("TripId"); + + b.ToTable("Segments"); + }); + + modelBuilder.Entity("Wayfarer.Models.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("citext"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Wayfarer.Models.TileCacheMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ETag") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAccessed") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LastModifiedUpstream") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("TileFilePath") + .HasColumnType("text"); + + b.Property("TileLocation") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("X") + .HasColumnType("integer"); + + b.Property("Y") + .HasColumnType("integer"); + + b.Property("Zoom") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TileLocation"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("TileLocation"), "GIST"); + + b.ToTable("TileCacheMetadata"); + }); + + modelBuilder.Entity("Wayfarer.Models.Trip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CenterLat") + .HasColumnType("double precision"); + + b.Property("CenterLon") + .HasColumnType("double precision"); + + b.Property("CoverImageUrl") + .HasColumnType("text"); + + b.Property("IsPublic") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("ShareProgressEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Zoom") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Trips"); + }); + + 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("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", 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("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TripTags", b => + { + b.HasOne("Wayfarer.Models.Tag", null) + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.Trip", null) + .WithMany() + .HasForeignKey("TripId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.ApiToken", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("ApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Area", b => + { + b.HasOne("Wayfarer.Models.Region", "Region") + .WithMany("Areas") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Wayfarer.Models.Group", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "Owner") + .WithMany("GroupsOwned") + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupInvitation", b => + { + b.HasOne("Wayfarer.Models.Group", "Group") + .WithMany("Invitations") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", "Invitee") + .WithMany("GroupInvitationsReceived") + .HasForeignKey("InviteeUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Wayfarer.Models.ApplicationUser", "Inviter") + .WithMany("GroupInvitationsSent") + .HasForeignKey("InviterUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Invitee"); + + b.Navigation("Inviter"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupMember", b => + { + b.HasOne("Wayfarer.Models.Group", "Group") + .WithMany("Members") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("GroupMemberships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.HiddenArea", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("HiddenAreas") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Location", b => + { + b.HasOne("Wayfarer.Models.ActivityType", "ActivityType") + .WithMany() + .HasForeignKey("ActivityTypeId"); + + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany("Locations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ActivityType"); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationImport", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("LocationImports") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Place", b => + { + b.HasOne("Wayfarer.Models.Region", "Region") + .WithMany("Places") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitCandidate", b => + { + b.HasOne("Wayfarer.Models.Place", "Place") + .WithMany() + .HasForeignKey("PlaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Place"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitEvent", b => + { + b.HasOne("Wayfarer.Models.Place", "Place") + .WithMany() + .HasForeignKey("PlaceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Place"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Region", b => + { + b.HasOne("Wayfarer.Models.Trip", "Trip") + .WithMany("Regions") + .HasForeignKey("TripId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trip"); + }); + + modelBuilder.Entity("Wayfarer.Models.Segment", b => + { + b.HasOne("Wayfarer.Models.Place", "FromPlace") + .WithMany() + .HasForeignKey("FromPlaceId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Wayfarer.Models.Place", "ToPlace") + .WithMany() + .HasForeignKey("ToPlaceId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Wayfarer.Models.Trip", "Trip") + .WithMany("Segments") + .HasForeignKey("TripId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FromPlace"); + + b.Navigation("ToPlace"); + + b.Navigation("Trip"); + }); + + modelBuilder.Entity("Wayfarer.Models.Trip", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("Trips") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.ApplicationUser", b => + { + b.Navigation("ApiTokens"); + + b.Navigation("GroupInvitationsReceived"); + + b.Navigation("GroupInvitationsSent"); + + b.Navigation("GroupMemberships"); + + b.Navigation("GroupsOwned"); + + b.Navigation("HiddenAreas"); + + b.Navigation("LocationImports"); + + b.Navigation("Locations"); + + b.Navigation("Trips"); + }); + + modelBuilder.Entity("Wayfarer.Models.Group", b => + { + b.Navigation("Invitations"); + + b.Navigation("Members"); + }); + + modelBuilder.Entity("Wayfarer.Models.Region", b => + { + b.Navigation("Areas"); + + b.Navigation("Places"); + }); + + modelBuilder.Entity("Wayfarer.Models.Trip", b => + { + b.Navigation("Regions"); + + b.Navigation("Segments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260322180500_AddAuthenticatedTileRateLimit.cs b/Migrations/20260322180500_AddAuthenticatedTileRateLimit.cs new file mode 100644 index 00000000..ebe966a6 --- /dev/null +++ b/Migrations/20260322180500_AddAuthenticatedTileRateLimit.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Wayfarer.Migrations +{ + /// + public partial class AddAuthenticatedTileRateLimit : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TileRateLimitAuthenticatedPerMinute", + table: "ApplicationSettings", + type: "integer", + nullable: false, + defaultValue: 2000); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "TileRateLimitAuthenticatedPerMinute", + table: "ApplicationSettings"); + } + } +} diff --git a/Migrations/ApplicationDbContextModelSnapshot.cs b/Migrations/ApplicationDbContextModelSnapshot.cs index 2bc2e3cf..710ee1bf 100644 --- a/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Migrations/ApplicationDbContextModelSnapshot.cs @@ -84,6 +84,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(500) .HasColumnType("character varying(500)"); + b.Property("TileRateLimitAuthenticatedPerMinute") + .HasColumnType("integer"); + b.Property("TileRateLimitEnabled") .HasColumnType("boolean"); diff --git a/Models/ApplicationSettings.cs b/Models/ApplicationSettings.cs index b82995e9..599e3358 100644 --- a/Models/ApplicationSettings.cs +++ b/Models/ApplicationSettings.cs @@ -15,6 +15,7 @@ public class ApplicationSettings public const string DefaultTileProviderUrlTemplate = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"; public const string DefaultTileProviderAttribution = "© OpenStreetMap contributors"; public const int DefaultTileRateLimitPerMinute = 500; + public const int DefaultTileRateLimitAuthenticatedPerMinute = 2000; public const int DefaultProxyImageRateLimitPerMinute = 200; public const int DefaultMaxProxyImageDownloadMB = 50; @@ -78,8 +79,9 @@ public class ApplicationSettings public string? TileProviderApiKey { get; set; } /// - /// Whether to rate limit anonymous tile requests to prevent abuse. - /// Authenticated users are never rate limited. + /// Whether to rate limit tile requests to prevent abuse. + /// When enabled, anonymous users are limited by (per IP) + /// and authenticated users by (per user ID). /// [Required] public bool TileRateLimitEnabled { get; set; } = true; @@ -92,6 +94,16 @@ public class ApplicationSettings [Range(50, 10000, ErrorMessage = "Rate limit must be between 50 and 10,000 requests per minute.")] public int TileRateLimitPerMinute { get; set; } = DefaultTileRateLimitPerMinute; + /// + /// Maximum tile requests per minute per user ID for authenticated users. + /// Higher than anonymous limit since authenticated users are trusted but should + /// still have a finite limit to prevent compromised account abuse. + /// Default is 2000. + /// + [Required] + [Range(100, 50000, ErrorMessage = "Authenticated rate limit must be between 100 and 50,000 requests per minute.")] + public int TileRateLimitAuthenticatedPerMinute { get; set; } = DefaultTileRateLimitAuthenticatedPerMinute; + /// /// Whether to rate limit anonymous proxy image requests to prevent abuse and origin flooding. /// Authenticated users are never rate limited. diff --git a/tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs b/tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs index a86d2936..17cc0897 100644 --- a/tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs +++ b/tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs @@ -3,6 +3,7 @@ using System.IO; using System.Net; using System.Net.Http; +using System.Security.Claims; using System.Threading; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -291,6 +292,93 @@ public async Task GetTile_RespectXForwardedFor() } } + [Fact] + public async Task GetTile_AuthenticatedUser_RateLimitedAtHigherThreshold() + { + var cacheDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(cacheDir); + + // Set a low authenticated limit to trigger it easily. + var settingsService = BuildSettingsService(rateLimitEnabled: true, rateLimitPerMinute: 2, rateLimitAuthenticatedPerMinute: 3); + var controller = BuildController(cacheDir: cacheDir, settingsService: settingsService); + controller.ControllerContext.HttpContext.Request.Headers["Referer"] = "http://example.com/page"; + + // Set up an authenticated user identity. + var userId = Guid.NewGuid().ToString(); + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) }; + var identity = new ClaimsIdentity(claims, "TestAuth"); + controller.ControllerContext.HttpContext.User = new ClaimsPrincipal(identity); + + try + { + // Authenticated user should get the higher limit (3, not 2). + // Use valid coordinates: at z=10, max tile index is 1023. + var result1 = await controller.GetTile(10, 0, 0); + Assert.IsNotType(result1); + + var result2 = await controller.GetTile(10, 0, 1); + Assert.IsNotType(result2); + + var result3 = await controller.GetTile(10, 0, 2); + Assert.IsNotType(result3); + + // Fourth request should be rate limited. + var result4 = await controller.GetTile(10, 0, 3); + var statusResult = Assert.IsType(result4); + Assert.Equal(429, statusResult.StatusCode); + } + finally + { + if (Directory.Exists(cacheDir)) + { + Directory.Delete(cacheDir, true); + } + } + } + + [Fact] + public async Task GetTile_AuthenticatedUser_UsesUserIdNotIp() + { + var cacheDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(cacheDir); + + // Set very low limit to trigger quickly. + var settingsService = BuildSettingsService(rateLimitEnabled: true, rateLimitPerMinute: 1, rateLimitAuthenticatedPerMinute: 1); + var controller = BuildController(cacheDir: cacheDir, settingsService: settingsService); + controller.ControllerContext.HttpContext.Request.Headers["Referer"] = "http://example.com/page"; + + // First user: exhaust their limit. + var user1Id = Guid.NewGuid().ToString(); + var identity1 = new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, user1Id) }, "TestAuth"); + controller.ControllerContext.HttpContext.User = new ClaimsPrincipal(identity1); + + try + { + var result1 = await controller.GetTile(10, 0, 0); + Assert.IsNotType(result1); + + // User1 should now be rate limited. + var result2 = await controller.GetTile(10, 0, 1); + var statusResult = Assert.IsType(result2); + Assert.Equal(429, statusResult.StatusCode); + + // Second user (same IP) should NOT be rate limited (keyed by userId, not IP). + var user2Id = Guid.NewGuid().ToString(); + var identity2 = new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, user2Id) }, "TestAuth"); + controller.ControllerContext.HttpContext.User = new ClaimsPrincipal(identity2); + + var result3 = await controller.GetTile(10, 0, 2); + Assert.IsNotType(result3); + } + finally + { + if (Directory.Exists(cacheDir)) + { + Directory.Delete(cacheDir, true); + } + } + } + private TilesController BuildController(TileCacheService? tileService = null, ApplicationDbContext? dbContext = null!, string? cacheDir = null, HttpMessageHandler? handler = null, IApplicationSettingsService? settingsService = null) { dbContext ??= CreateDbContext(); @@ -337,7 +425,7 @@ private TileCacheService CreateTileService(ApplicationDbContext dbContext, HttpM new HttpContextAccessor()); } - private IApplicationSettingsService BuildSettingsService(bool rateLimitEnabled = true, int rateLimitPerMinute = 500) + private IApplicationSettingsService BuildSettingsService(bool rateLimitEnabled = true, int rateLimitPerMinute = 500, int rateLimitAuthenticatedPerMinute = 2000) { // Use a consistent settings instance for controller + cache service tests. var appSettings = new Mock(); @@ -348,7 +436,8 @@ private IApplicationSettingsService BuildSettingsService(bool rateLimitEnabled = TileProviderUrlTemplate = ApplicationSettings.DefaultTileProviderUrlTemplate, TileProviderAttribution = ApplicationSettings.DefaultTileProviderAttribution, TileRateLimitEnabled = rateLimitEnabled, - TileRateLimitPerMinute = rateLimitPerMinute + TileRateLimitPerMinute = rateLimitPerMinute, + TileRateLimitAuthenticatedPerMinute = rateLimitAuthenticatedPerMinute }); return appSettings.Object; } From fdbd50f74fdda399ac1e4df5ce11877cccd94cdd Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 22 Mar 2026 20:09:33 +0200 Subject: [PATCH 03/18] WIP: outbound request budget for OSM protection (checkpoint) --- Services/TileCacheService.cs | 111 ++++++++++++++++++ .../Services/TileCacheServiceTests.cs | 16 +++ 2 files changed, 127 insertions(+) diff --git a/Services/TileCacheService.cs b/Services/TileCacheService.cs index 75ded3a9..a32078ff 100644 --- a/Services/TileCacheService.cs +++ b/Services/TileCacheService.cs @@ -88,6 +88,104 @@ public class TileCacheService WriteIndented = false }; + /// + /// Token-bucket rate limiter for outbound requests to upstream tile providers (e.g., OSM). + /// Prevents cache-miss cascading from overwhelming the upstream server and risking a block + /// under OSM's fair use policy. Replenishes at a fixed rate (default: 2 tokens/sec) + /// with a small burst capacity (default: 4 concurrent requests). + /// Thread-safe: uses for token management and + /// for replenishment. + /// + internal static class OutboundBudget + { + /// + /// Maximum burst capacity — how many outbound requests can fire concurrently + /// before throttling kicks in. Allows short bursts during cache warm-up. + /// + private const int BurstCapacity = 4; + + /// + /// Replenishment interval — one token is released every this many milliseconds. + /// 500ms = 2 tokens/sec sustained rate, which is well within OSM's fair use policy. + /// + private const int ReplenishIntervalMs = 500; + + /// + /// Maximum time to wait for a token before giving up. Callers that time out + /// serve stale cache or return 503 (graceful degradation). + /// + internal static readonly TimeSpan AcquireTimeout = TimeSpan.FromSeconds(5); + + /// + /// Semaphore representing available outbound tokens. Initialized to . + /// Each call consumes one token; the replenishment task restores them. + /// + private static readonly SemaphoreSlim _tokens = new(BurstCapacity, BurstCapacity); + + /// + /// Ensures the replenishment task is started exactly once, even under concurrent access. + /// + private static readonly Lazy _replenisher = new( + StartReplenisher, LazyThreadSafetyMode.ExecutionAndPublication); + + /// + /// Attempts to acquire a token for an outbound request. Returns true if a token was + /// obtained within the timeout, false if the budget is exhausted. + /// Automatically starts the replenishment background task on first call. + /// + /// Cancellation token for the calling request. + /// True if a token was acquired and the outbound request may proceed. + internal static async Task AcquireAsync(CancellationToken cancellationToken = default) + { + // Ensure the replenishment task is running (no-op after first call). + _ = _replenisher.Value; + return await _tokens.WaitAsync(AcquireTimeout, cancellationToken).ConfigureAwait(false); + } + + /// + /// Starts a long-running background task that releases one semaphore token every + /// milliseconds, maintaining the sustained outbound rate. + /// Uses for efficient, non-blocking scheduling. + /// + private static Task StartReplenisher() + { + return Task.Run(async () => + { + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(ReplenishIntervalMs)); + while (await timer.WaitForNextTickAsync().ConfigureAwait(false)) + { + // Release a token if below capacity. CurrentCount check avoids + // SemaphoreFullException when replenishment outpaces consumption. + if (_tokens.CurrentCount < BurstCapacity) + { + try + { + _tokens.Release(); + } + catch (SemaphoreFullException) + { + // Harmless race: another thread released between our check and Release(). + } + } + } + }); + } + + /// + /// Resets the outbound budget for testing. Drains and refills the semaphore to burst capacity. + /// + internal static void ResetForTesting() + { + // Drain all tokens. + while (_tokens.CurrentCount > 0) + { + _tokens.Wait(0); + } + // Refill to burst capacity. + _tokens.Release(BurstCapacity); + } + } + /// /// Resets all static state so each test starts with a clean slate. /// Must be called between tests to prevent cross-test interference from @@ -99,6 +197,7 @@ internal static void ResetStaticStateForTesting() _sidecarCache.Clear(); Interlocked.Exchange(ref _currentCacheSize, 0); _cacheSizeInitialized = false; + OutboundBudget.ResetForTesting(); } public TileCacheService(ILogger logger, IConfiguration configuration, HttpClient httpClient, @@ -218,11 +317,23 @@ public string GetCacheDirectory() /// /// Core tile request method with same-host redirect policy and Referer header. + /// Acquires an outbound budget token before sending to comply with OSM's fair use policy. + /// Returns null if the budget is exhausted (callers degrade gracefully with stale cache). /// Accepts an optional delegate for customizing request headers (e.g., conditional headers). /// private async Task SendTileRequestCoreAsync(string tileUrl, Action? configureRequest = null) { + // Acquire an outbound request token. If the budget is exhausted, return null + // so callers can gracefully degrade (serve stale cache or return 503). + if (!await OutboundBudget.AcquireAsync().ConfigureAwait(false)) + { + _logger.LogWarning( + "Outbound request budget exhausted — throttling upstream request for {TileUrl}", + TileProviderCatalog.RedactApiKey(tileUrl)); + return null; + } + const int maxRedirects = 3; var initialUri = new Uri(tileUrl); var currentUri = initialUri; diff --git a/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs b/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs index 8e591b2f..fe7532e4 100644 --- a/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs +++ b/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs @@ -681,6 +681,22 @@ protected override Task SendAsync(HttpRequestMessage reques } } + /// + /// Verifies that the outbound budget throttle can be acquired and limits concurrent requests. + /// + [Fact] + public async Task OutboundBudget_AcquireAsync_GrantsTokensUpToBurstCapacity() + { + TileCacheService.OutboundBudget.ResetForTesting(); + + // Burst capacity is 4 — first 4 should succeed immediately. + for (int i = 0; i < 4; i++) + { + var acquired = await TileCacheService.OutboundBudget.AcquireAsync(); + Assert.True(acquired, $"Token {i + 1} should have been acquired"); + } + } + private sealed class StubSettingsService : IApplicationSettingsService { private readonly int _maxCache; From 6eb7144022804a5128aea2225b395f60915803e4 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 22 Mar 2026 20:14:39 +0200 Subject: [PATCH 04/18] Security: harden tile proxy with sliding-window rate limiter, outbound budget, and auth rate limits (#204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace fixed-window rate limiter with sliding-window counter approximation to prevent boundary-batching attacks - Add authenticated user rate limiting by userId (default 2000/min) — previously authenticated users bypassed rate limiting entirely - Add outbound request budget (token-bucket at 2/sec, burst 4) to prevent cache-miss cascading from overwhelming upstream OSM - Add X-Content-Type-Options: nosniff on tile responses - Clean shutdown of outbound budget replenisher via IHostApplicationLifetime --- CHANGELOG.md | 14 +++++++ Program.cs | 3 ++ Services/RateLimitHelper.cs | 9 ++++- Services/TileCacheService.cs | 78 ++++++++++++++++++++++++++++-------- 4 files changed, 86 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e85fc552..3db511a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # CHANGELOG +## [1.2.18] - 2026-03-22 + +### Added +- Sliding-window rate limiter replacing fixed-window — prevents boundary-batching attacks where bursts at window edges could double the effective limit (#204) +- Authenticated user rate limiting by user ID (default 2000 req/min) — previously authenticated users bypassed rate limiting entirely (#204) +- `TileRateLimitAuthenticatedPerMinute` application setting for configurable authenticated tile rate limit (#204) +- Outbound request budget (token-bucket at 2 req/sec, burst 4) — prevents cache-miss cascading from overwhelming upstream OSM and risking a fair-use block (#204) +- `X-Content-Type-Options: nosniff` header on tile proxy responses to prevent MIME-sniffing (#204) + +### Changed +- Rate limiter now uses sliding-window counter approximation instead of fixed-window, smoothing request counting across window boundaries (#204) +- Rate limiting applies to both anonymous (by IP) and authenticated (by user ID) requests with separate configurable thresholds (#204) +- Outbound tile requests gracefully degrade (serve stale cache) when upstream budget is exhausted (#204) + ## [1.2.17] - 2026-03-22 ### Added diff --git a/Program.cs b/Program.cs index 4ddd5079..acbae31a 100644 --- a/Program.cs +++ b/Program.cs @@ -162,6 +162,9 @@ static void ConfigureForwardedHeaders(WebApplicationBuilder builder) "environment variable in your systemd service file."); } +// Stop the outbound budget replenisher on graceful shutdown to avoid dangling background tasks. +app.Lifetime.ApplicationStopping.Register(TileCacheService.StopOutboundBudget); + app.Run(); static Task LoadUploadSizeLimitFromDatabaseAsync() diff --git a/Services/RateLimitHelper.cs b/Services/RateLimitHelper.cs index b9c17ae1..188d8804 100644 --- a/Services/RateLimitHelper.cs +++ b/Services/RateLimitHelper.cs @@ -7,7 +7,10 @@ namespace Wayfarer.Services; /// /// Shared rate limiting utility using a sliding-window counter approximation with 1-minute windows. /// Prevents boundary-batching attacks where a burst at the end of one window plus the start of the -/// next could double the effective limit. Thread-safe: uses atomic operations to prevent race conditions. +/// next could double the effective limit. Thread-safe: uses atomic operations to minimize race conditions. +/// Note: during window rotation there is a narrow race where a thread can increment the old window's +/// count after the CAS but before the reset, causing a minor undercount (off by ~1). This is acceptable +/// for rate limiting purposes and does not compromise the overall throttling guarantee. /// Used by and /// . /// @@ -23,7 +26,9 @@ public static class RateLimitHelper /// counter approximation. Maintains the previous window's count so that requests near a /// boundary are weighted, preventing the boundary-batching exploit where an attacker sends /// the full limit at :59s and again at :00s to achieve 2× the intended rate. - /// Uses atomic operations (Interlocked) for thread safety. + /// Uses atomic operations (Interlocked) for thread safety. The weighted count reads multiple + /// fields non-atomically, so it may jitter by ~1 request during window rotation — acceptable + /// for rate limiting. /// public sealed class RateLimitEntry { diff --git a/Services/TileCacheService.cs b/Services/TileCacheService.cs index a32078ff..42ccc264 100644 --- a/Services/TileCacheService.cs +++ b/Services/TileCacheService.cs @@ -122,11 +122,17 @@ internal static class OutboundBudget /// private static readonly SemaphoreSlim _tokens = new(BurstCapacity, BurstCapacity); + /// + /// Cancellation source for stopping the replenishment task during shutdown or testing. + /// + private static CancellationTokenSource _replenisherCts = new(); + /// /// Ensures the replenishment task is started exactly once, even under concurrent access. /// - private static readonly Lazy _replenisher = new( - StartReplenisher, LazyThreadSafetyMode.ExecutionAndPublication); + private static Lazy _replenisher = new( + () => StartReplenisher(_replenisherCts.Token), + LazyThreadSafetyMode.ExecutionAndPublication); /// /// Attempts to acquire a token for an outbound request. Returns true if a token was @@ -146,46 +152,86 @@ internal static async Task AcquireAsync(CancellationToken cancellationToke /// Starts a long-running background task that releases one semaphore token every /// milliseconds, maintaining the sustained outbound rate. /// Uses for efficient, non-blocking scheduling. + /// Stops cleanly when the is cancelled (e.g., during app shutdown). /// - private static Task StartReplenisher() + /// Cancellation token that stops the replenisher loop. + private static Task StartReplenisher(CancellationToken ct) { return Task.Run(async () => { using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(ReplenishIntervalMs)); - while (await timer.WaitForNextTickAsync().ConfigureAwait(false)) + try { - // Release a token if below capacity. CurrentCount check avoids - // SemaphoreFullException when replenishment outpaces consumption. - if (_tokens.CurrentCount < BurstCapacity) + while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false)) { - try - { - _tokens.Release(); - } - catch (SemaphoreFullException) + // Release a token if below capacity. CurrentCount check avoids + // SemaphoreFullException when replenishment outpaces consumption. + if (_tokens.CurrentCount < BurstCapacity) { - // Harmless race: another thread released between our check and Release(). + try + { + _tokens.Release(); + } + catch (SemaphoreFullException) + { + // Harmless race: another thread released between our check and Release(). + } } } } - }); + catch (OperationCanceledException) + { + // Expected during shutdown or test reset — exit cleanly. + } + }, ct); } /// - /// Resets the outbound budget for testing. Drains and refills the semaphore to burst capacity. + /// Stops the replenishment task if running, resets the Lazy so a fresh replenisher + /// can start on the next call. + /// Called by on app shutdown + /// and by between tests. + /// + private static void StopReplenisher() + { + _replenisherCts.Cancel(); + _replenisherCts.Dispose(); + _replenisherCts = new CancellationTokenSource(); + _replenisher = new Lazy( + () => StartReplenisher(_replenisherCts.Token), + LazyThreadSafetyMode.ExecutionAndPublication); + } + + /// + /// Resets the outbound budget for testing. Stops the replenisher, drains and refills + /// the semaphore to burst capacity, then allows a fresh replenisher on next acquire. /// internal static void ResetForTesting() { + StopReplenisher(); // Drain all tokens. while (_tokens.CurrentCount > 0) { _tokens.Wait(0); } // Refill to burst capacity. - _tokens.Release(BurstCapacity); + try + { + _tokens.Release(BurstCapacity); + } + catch (SemaphoreFullException) + { + // Already at capacity after drain — safe to ignore. + } } } + /// + /// Stops the outbound budget replenishment task for clean application shutdown. + /// Call from IHostApplicationLifetime.ApplicationStopping or equivalent. + /// + public static void StopOutboundBudget() => OutboundBudget.ResetForTesting(); + /// /// Resets all static state so each test starts with a clean slate. /// Must be called between tests to prevent cross-test interference from From a0052bd823c71d3826a6deb9fcdc0ec788f02927 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 22 Mar 2026 20:26:36 +0200 Subject: [PATCH 05/18] Fix all review findings: admin UI, OSM compliance, concurrency, usability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CRITICAL: Add TileRateLimitAuthenticatedPerMinute to admin settings UI, controller tracking, and property assignment; fix incorrect "never limited" text - HIGH: Reduce outbound burst to 2 (OSM 2-connection policy), increase timeout to 10s for cold-cache resilience - HIGH: Fix CTS disposal race in StopReplenisher — cancel without disposing, let GC handle abandoned CTS; mark fields volatile for cross-thread visibility - HIGH: Bump default anonymous rate limit from 500 to 600 to compensate for stricter sliding-window algorithm - MEDIUM: Fall back to IP-based rate limiting when authenticated user lacks NameIdentifier claim instead of pooling into shared "unknown" bucket - MEDIUM: Update sliding-window jitter docs to reflect actual error bound (prevCount × ~0.5 during rotation, not "~1") - MEDIUM: Separate Stop() for production shutdown from ResetForTesting() to avoid unnecessary drain/refill during app shutdown - MEDIUM: Add test for outbound budget exhaustion returning false - LOW: Fix eviction _currentCacheSize undercount — decrement after SaveChangesAsync succeeds, not before - LOW: Move File.Exists check inside _cacheLock in eviction to fix TOCTOU - LOW: Coalesce concurrent cleanup runs with Interlocked flag; reduce cleanup threshold from 100k to 10k entries --- Areas/Admin/Controllers/SettingsController.cs | 2 + Areas/Admin/Views/Settings/Index.cshtml | 36 +++++--- Areas/Public/Controllers/TilesController.cs | 9 +- CHANGELOG.md | 9 +- Models/ApplicationSettings.cs | 2 +- Services/RateLimitHelper.cs | 38 +++++++-- Services/TileCacheService.cs | 82 ++++++++++++------- .../Services/TileCacheServiceTests.cs | 36 +++++++- 8 files changed, 154 insertions(+), 60 deletions(-) diff --git a/Areas/Admin/Controllers/SettingsController.cs b/Areas/Admin/Controllers/SettingsController.cs index b0df3979..0fe41f6b 100644 --- a/Areas/Admin/Controllers/SettingsController.cs +++ b/Areas/Admin/Controllers/SettingsController.cs @@ -176,6 +176,7 @@ void Track(string name, T oldVal, T newVal) } Track("TileRateLimitEnabled", currentSettings.TileRateLimitEnabled, updatedSettings.TileRateLimitEnabled); Track("TileRateLimitPerMinute", currentSettings.TileRateLimitPerMinute, updatedSettings.TileRateLimitPerMinute); + Track("TileRateLimitAuthenticatedPerMinute", currentSettings.TileRateLimitAuthenticatedPerMinute, updatedSettings.TileRateLimitAuthenticatedPerMinute); // Trip Place Auto-Visited settings Track("VisitedRequiredHits", currentSettings.VisitedRequiredHits, updatedSettings.VisitedRequiredHits); @@ -219,6 +220,7 @@ void Track(string name, T oldVal, T newVal) currentSettings.TileProviderApiKey = updatedSettings.TileProviderApiKey; currentSettings.TileRateLimitEnabled = updatedSettings.TileRateLimitEnabled; currentSettings.TileRateLimitPerMinute = updatedSettings.TileRateLimitPerMinute; + currentSettings.TileRateLimitAuthenticatedPerMinute = updatedSettings.TileRateLimitAuthenticatedPerMinute; // Trip Place Auto-Visited settings currentSettings.VisitedRequiredHits = updatedSettings.VisitedRequiredHits; diff --git a/Areas/Admin/Views/Settings/Index.cshtml b/Areas/Admin/Views/Settings/Index.cshtml index 8f628c4c..260bee9a 100644 --- a/Areas/Admin/Views/Settings/Index.cshtml +++ b/Areas/Admin/Views/Settings/Index.cshtml @@ -578,28 +578,28 @@

Tile Request Rate Limiting

- Protect the tile proxy from abuse by rate limiting anonymous requests. - Authenticated users (logged-in) are never rate limited. + Protect the tile proxy from abuse by rate limiting tile requests. + Anonymous users are limited by IP address; authenticated users by user ID at a higher threshold.

-
+
- When enabled, anonymous tile requests are limited per IP address. + When enabled, tile requests are limited for all users.
-
- +
+
req/min @@ -610,14 +610,26 @@
-
+
+ +
+ + req/min +
+ + Default: @ApplicationSettings.DefaultTileRateLimitAuthenticatedPerMinute. Higher limit for trusted users. + + +
+ +
Who is affected:
    -
  • Not limited: Logged-in users
  • -
  • Limited: Embeds, public pages, anonymous visitors
  • +
  • Logged-in: Limited per user ID
  • +
  • Anonymous: Limited per IP address
- 500/min is generous for normal viewing. + Both limits use sliding-window counting.
diff --git a/Areas/Public/Controllers/TilesController.cs b/Areas/Public/Controllers/TilesController.cs index 45ac1f9f..0fc35009 100644 --- a/Areas/Public/Controllers/TilesController.cs +++ b/Areas/Public/Controllers/TilesController.cs @@ -89,9 +89,13 @@ public async Task GetTile(int z, int x, int y) // Anonymous users are limited by IP; authenticated users by user ID at a higher threshold. if (settings.TileRateLimitEnabled) { - if (User.Identity?.IsAuthenticated == true) + var userId = User.Identity?.IsAuthenticated == true + ? User.FindFirstValue(ClaimTypes.NameIdentifier) + : null; + + if (userId != null) { - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown"; + // Authenticated user with a valid user ID — rate limit by userId. if (RateLimitHelper.IsRateLimitExceeded(AuthRateLimitCache, userId, settings.TileRateLimitAuthenticatedPerMinute)) { _logger.LogWarning("Tile rate limit exceeded for authenticated user: {UserId}", userId); @@ -100,6 +104,7 @@ public async Task GetTile(int z, int x, int y) } else { + // Anonymous user or authenticated user without a NameIdentifier claim — rate limit by IP. var clientIp = GetClientIpAddress(); if (RateLimitHelper.IsRateLimitExceeded(RateLimitCache, clientIp, settings.TileRateLimitPerMinute)) { diff --git a/CHANGELOG.md b/CHANGELOG.md index 3db511a7..915d87f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,15 +5,20 @@ ### Added - Sliding-window rate limiter replacing fixed-window — prevents boundary-batching attacks where bursts at window edges could double the effective limit (#204) - Authenticated user rate limiting by user ID (default 2000 req/min) — previously authenticated users bypassed rate limiting entirely (#204) -- `TileRateLimitAuthenticatedPerMinute` application setting for configurable authenticated tile rate limit (#204) -- Outbound request budget (token-bucket at 2 req/sec, burst 4) — prevents cache-miss cascading from overwhelming upstream OSM and risking a fair-use block (#204) +- `TileRateLimitAuthenticatedPerMinute` application setting for configurable authenticated tile rate limit, exposed in Admin Settings UI (#204) +- Outbound request budget (token-bucket at 2 req/sec, burst 2) — prevents cache-miss cascading from overwhelming upstream OSM and risking a fair-use block; complies with OSM 2-connection policy (#204) - `X-Content-Type-Options: nosniff` header on tile proxy responses to prevent MIME-sniffing (#204) ### Changed - Rate limiter now uses sliding-window counter approximation instead of fixed-window, smoothing request counting across window boundaries (#204) +- Default anonymous tile rate limit increased from 500 to 600 req/min to compensate for the stricter sliding-window algorithm (#204) - Rate limiting applies to both anonymous (by IP) and authenticated (by user ID) requests with separate configurable thresholds (#204) +- Admin Settings UI updated to show both anonymous and authenticated rate limit fields; removed incorrect "never limited" text (#204) - Outbound tile requests gracefully degrade (serve stale cache) when upstream budget is exhausted (#204) +### Fixed +- Eviction `_currentCacheSize` tracking now decrements after successful DB commit, preventing permanent undercount on failed eviction (#204) + ## [1.2.17] - 2026-03-22 ### Added diff --git a/Models/ApplicationSettings.cs b/Models/ApplicationSettings.cs index 599e3358..44eaf693 100644 --- a/Models/ApplicationSettings.cs +++ b/Models/ApplicationSettings.cs @@ -14,7 +14,7 @@ public class ApplicationSettings public const string DefaultTileProviderKey = "osm"; public const string DefaultTileProviderUrlTemplate = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"; public const string DefaultTileProviderAttribution = "© OpenStreetMap contributors"; - public const int DefaultTileRateLimitPerMinute = 500; + public const int DefaultTileRateLimitPerMinute = 600; public const int DefaultTileRateLimitAuthenticatedPerMinute = 2000; public const int DefaultProxyImageRateLimitPerMinute = 200; public const int DefaultMaxProxyImageDownloadMB = 50; diff --git a/Services/RateLimitHelper.cs b/Services/RateLimitHelper.cs index 188d8804..6ec8a7a8 100644 --- a/Services/RateLimitHelper.cs +++ b/Services/RateLimitHelper.cs @@ -8,9 +8,12 @@ namespace Wayfarer.Services; /// Shared rate limiting utility using a sliding-window counter approximation with 1-minute windows. /// Prevents boundary-batching attacks where a burst at the end of one window plus the start of the /// next could double the effective limit. Thread-safe: uses atomic operations to minimize race conditions. -/// Note: during window rotation there is a narrow race where a thread can increment the old window's -/// count after the CAS but before the reset, causing a minor undercount (off by ~1). This is acceptable -/// for rate limiting purposes and does not compromise the overall throttling guarantee. +/// Note: during window rotation there are two sources of jitter: +/// 1. A thread can increment the old window's count after the CAS but before the reset, causing an +/// undercount by the number of concurrent threads in that window (typically 1-2, up to thread count). +/// 2. The weighted count reads _windowStartTicks, _expirationTicks, and _prevCount non-atomically, +/// so during rotation the weight can be skewed by up to prevCount × ~0.5 in the worst case. +/// Both effects are transient (lasting only the rotation instant) and acceptable for rate limiting. /// Used by and /// . ///
@@ -27,8 +30,8 @@ public static class RateLimitHelper /// boundary are weighted, preventing the boundary-batching exploit where an attacker sends /// the full limit at :59s and again at :00s to achieve 2× the intended rate. /// Uses atomic operations (Interlocked) for thread safety. The weighted count reads multiple - /// fields non-atomically, so it may jitter by ~1 request during window rotation — acceptable - /// for rate limiting. + /// fields non-atomically, so it may jitter by up to prevCount × ~0.5 during window rotation — + /// transient and acceptable for rate limiting. /// public sealed class RateLimitEntry { @@ -110,20 +113,37 @@ public int IncrementAndGet(long currentTicks, long newExpirationTicks) /// The concurrent dictionary tracking rate limit entries per IP. /// The client IP address to check. /// Maximum allowed requests per minute. - /// Maximum number of IPs to track before cleanup triggers. + /// Maximum number of keys to track before cleanup triggers (default 10,000). /// True if rate limit is exceeded, false otherwise. + /// + /// Coalesces concurrent cleanup runs. Only one thread runs cleanup at a time; + /// others skip and proceed with rate limiting. + /// + private static int _cleanupInProgress; + public static bool IsRateLimitExceeded( ConcurrentDictionary cache, string clientIp, int maxRequestsPerMinute, - int maxTrackedIps = 100000) + int maxTrackedIps = 10000) { var currentTicks = DateTime.UtcNow.Ticks; - var expirationTicks = currentTicks + TimeSpan.FromMinutes(1).Ticks; + var expirationTicks = currentTicks + WindowTicks; if (cache.Count > maxTrackedIps) { - CleanupExpiredEntries(cache, currentTicks); + // Coalesce: only one thread runs cleanup at a time. + if (Interlocked.CompareExchange(ref _cleanupInProgress, 1, 0) == 0) + { + try + { + CleanupExpiredEntries(cache, currentTicks); + } + finally + { + Interlocked.Exchange(ref _cleanupInProgress, 0); + } + } } var entry = cache.GetOrAdd(clientIp, _ => new RateLimitEntry(expirationTicks)); diff --git a/Services/TileCacheService.cs b/Services/TileCacheService.cs index 42ccc264..58a14dda 100644 --- a/Services/TileCacheService.cs +++ b/Services/TileCacheService.cs @@ -91,30 +91,30 @@ public class TileCacheService /// /// Token-bucket rate limiter for outbound requests to upstream tile providers (e.g., OSM). /// Prevents cache-miss cascading from overwhelming the upstream server and risking a block - /// under OSM's fair use policy. Replenishes at a fixed rate (default: 2 tokens/sec) - /// with a small burst capacity (default: 4 concurrent requests). + /// under OSM's fair use policy. Replenishes at a sustained rate of 2 tokens/sec with a + /// burst capacity of 2 concurrent requests (matching OSM's 2-connection recommendation). /// Thread-safe: uses for token management and /// for replenishment. /// internal static class OutboundBudget { /// - /// Maximum burst capacity — how many outbound requests can fire concurrently - /// before throttling kicks in. Allows short bursts during cache warm-up. + /// Maximum burst capacity — how many outbound requests can fire concurrently. + /// Set to 2 to comply with OSM tile usage policy ("maximum of 2 download threads"). /// - private const int BurstCapacity = 4; + internal const int BurstCapacity = 2; /// /// Replenishment interval — one token is released every this many milliseconds. - /// 500ms = 2 tokens/sec sustained rate, which is well within OSM's fair use policy. + /// 500ms = 2 tokens/sec sustained rate, complying with OSM's fair use policy. /// - private const int ReplenishIntervalMs = 500; + internal const int ReplenishIntervalMs = 500; /// /// Maximum time to wait for a token before giving up. Callers that time out /// serve stale cache or return 503 (graceful degradation). /// - internal static readonly TimeSpan AcquireTimeout = TimeSpan.FromSeconds(5); + internal static readonly TimeSpan AcquireTimeout = TimeSpan.FromSeconds(10); /// /// Semaphore representing available outbound tokens. Initialized to . @@ -124,13 +124,16 @@ internal static class OutboundBudget /// /// Cancellation source for stopping the replenishment task during shutdown or testing. + /// Not disposed on stop — the replenisher task may still hold a reference to its token. + /// Old instances are abandoned for GC to avoid ObjectDisposedException races. /// - private static CancellationTokenSource _replenisherCts = new(); + private static volatile CancellationTokenSource _replenisherCts = new(); /// /// Ensures the replenishment task is started exactly once, even under concurrent access. + /// Declared volatile so replacement in is visible across threads. /// - private static Lazy _replenisher = new( + private static volatile Lazy _replenisher = new( () => StartReplenisher(_replenisherCts.Token), LazyThreadSafetyMode.ExecutionAndPublication); @@ -164,8 +167,8 @@ private static Task StartReplenisher(CancellationToken ct) { while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false)) { - // Release a token if below capacity. CurrentCount check avoids - // SemaphoreFullException when replenishment outpaces consumption. + // Release a token if below capacity. The catch handles the harmless race + // where CurrentCount changes between the check and the Release. if (_tokens.CurrentCount < BurstCapacity) { try @@ -183,28 +186,39 @@ private static Task StartReplenisher(CancellationToken ct) { // Expected during shutdown or test reset — exit cleanly. } - }, ct); + }, CancellationToken.None); } /// - /// Stops the replenishment task if running, resets the Lazy so a fresh replenisher - /// can start on the next call. - /// Called by on app shutdown - /// and by between tests. + /// Cancels the current replenishment task and prepares a fresh Lazy so a new replenisher + /// starts on the next call. Does NOT dispose the old CTS — + /// the replenisher task may still reference its token; abandoned CTS instances are GC'd. /// private static void StopReplenisher() { - _replenisherCts.Cancel(); - _replenisherCts.Dispose(); + var oldCts = _replenisherCts; _replenisherCts = new CancellationTokenSource(); _replenisher = new Lazy( () => StartReplenisher(_replenisherCts.Token), LazyThreadSafetyMode.ExecutionAndPublication); + oldCts.Cancel(); + } + + /// + /// Stops the replenishment task for clean application shutdown. Does not drain or refill + /// tokens — simply cancels the background task. Called from + /// IHostApplicationLifetime.ApplicationStopping. + /// + internal static void Stop() + { + _replenisherCts.Cancel(); } /// /// Resets the outbound budget for testing. Stops the replenisher, drains and refills - /// the semaphore to burst capacity, then allows a fresh replenisher on next acquire. + /// the semaphore to burst capacity, then prepares a fresh replenisher for the next acquire. + /// Must only be called when no concurrent calls are in flight + /// (i.e., between tests in a single-threaded setup phase). /// internal static void ResetForTesting() { @@ -230,7 +244,7 @@ internal static void ResetForTesting() /// Stops the outbound budget replenishment task for clean application shutdown. /// Call from IHostApplicationLifetime.ApplicationStopping or equivalent. /// - public static void StopOutboundBudget() => OutboundBudget.ResetForTesting(); + public static void StopOutboundBudget() => OutboundBudget.Stop(); /// /// Resets all static state so each test starts with a clean slate. @@ -1215,10 +1229,14 @@ private async Task EvictDbTilesAsync() .Take(LRU_TO_EVICT) // Adjust the eviction batch size as needed. .ToListAsync(); + // Track total evicted size; only decrement _currentCacheSize after SaveChangesAsync + // succeeds to avoid permanent undercount if the DB commit fails. + long totalEvictedSize = 0; + foreach (var tile in tilesToEvict) { _dbContext.TileCacheMetadata.Remove(tile); - Interlocked.Add(ref _currentCacheSize, -tile.Size); + totalEvictedSize += tile.Size; // Remove the corresponding file. var tileFilePath = Path.Combine(_cacheDirectory, $"{tile.Zoom}_{tile.X}_{tile.Y}.png"); @@ -1230,19 +1248,19 @@ private async Task EvictDbTilesAsync() try { - if (File.Exists(tileFilePath)) + await _cacheLock.WaitAsync(); + try { - await _cacheLock.WaitAsync(); - try + // Re-check under lock to avoid TOCTOU with concurrent cache reads/writes. + if (File.Exists(tileFilePath)) { - // Serialize file deletes with cache reads/writes. File.Delete(tileFilePath); + _logger.LogInformation("Tile file deleted: {TileFilePath}", tileFilePath); } - finally - { - _cacheLock.Release(); - } - _logger.LogInformation("Tile file deleted: {TileFilePath}", tileFilePath); + } + finally + { + _cacheLock.Release(); } } catch (Exception ex) @@ -1252,6 +1270,8 @@ private async Task EvictDbTilesAsync() } await _dbContext.SaveChangesAsync(); + // Decrement after successful commit to keep _currentCacheSize consistent with DB reality. + Interlocked.Add(ref _currentCacheSize, -totalEvictedSize); _logger.LogInformation("Evicted tiles to maintain cache size."); } diff --git a/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs b/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs index fe7532e4..3204eb0e 100644 --- a/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs +++ b/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs @@ -682,21 +682,51 @@ protected override Task SendAsync(HttpRequestMessage reques } /// - /// Verifies that the outbound budget throttle can be acquired and limits concurrent requests. + /// Verifies that the outbound budget throttle can be acquired up to burst capacity. /// [Fact] public async Task OutboundBudget_AcquireAsync_GrantsTokensUpToBurstCapacity() { TileCacheService.OutboundBudget.ResetForTesting(); - // Burst capacity is 4 — first 4 should succeed immediately. - for (int i = 0; i < 4; i++) + // Burst capacity is 2 — first 2 should succeed immediately. + for (int i = 0; i < TileCacheService.OutboundBudget.BurstCapacity; i++) { var acquired = await TileCacheService.OutboundBudget.AcquireAsync(); Assert.True(acquired, $"Token {i + 1} should have been acquired"); } } + /// + /// Verifies that once all burst tokens are consumed and the replenisher hasn't ticked yet, + /// further acquire attempts time out (return false) rather than blocking forever. + /// + [Fact] + public async Task OutboundBudget_AcquireAsync_ReturnsFalse_WhenBudgetExhausted() + { + TileCacheService.OutboundBudget.ResetForTesting(); + + // Consume all burst tokens. + for (int i = 0; i < TileCacheService.OutboundBudget.BurstCapacity; i++) + { + await TileCacheService.OutboundBudget.AcquireAsync(); + } + + // Use a short timeout to verify exhaustion without waiting the full AcquireTimeout. + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + bool acquired; + try + { + acquired = await TileCacheService.OutboundBudget.AcquireAsync(cts.Token); + } + catch (OperationCanceledException) + { + acquired = false; + } + + Assert.False(acquired, "Should not acquire a token when budget is exhausted"); + } + private sealed class StubSettingsService : IApplicationSettingsService { private readonly int _maxCache; From 4efb56c1358a41773e61f860347cec18e40dc7d4 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 22 Mar 2026 20:31:47 +0200 Subject: [PATCH 06/18] Fix checkbox unchecking in admin settings (hidden field fallback) Add hidden field fallback for TileRateLimitEnabled and IsRegistrationOpen checkboxes so unchecking them correctly posts "false" to the model binder. Without the hidden field, unchecked checkboxes send no value and ASP.NET falls back to the C# property default (true for TileRateLimitEnabled), making it impossible to disable rate limiting from the admin UI. --- Areas/Admin/Views/Settings/Index.cshtml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Areas/Admin/Views/Settings/Index.cshtml b/Areas/Admin/Views/Settings/Index.cshtml index 260bee9a..f588a282 100644 --- a/Areas/Admin/Views/Settings/Index.cshtml +++ b/Areas/Admin/Views/Settings/Index.cshtml @@ -455,6 +455,8 @@ + @* Hidden fallback ensures "false" is posted when checkbox is unchecked *@ + @@ -587,6 +589,8 @@ + @* Hidden fallback ensures "false" is posted when checkbox is unchecked *@ + internal static class OutboundBudget { /// - /// Maximum burst capacity — how many outbound requests can fire concurrently. - /// Set to 2 to comply with OSM tile usage policy ("maximum of 2 download threads"). + /// Maximum burst capacity — how many outbound requests can proceed without waiting + /// for replenishment. Set to 10 to allow initial map loads to proceed quickly on + /// a cold cache. OSM's 2-connection limit is enforced at the transport layer via + /// SocketsHttpHandler.MaxConnectionsPerServer = 2 in Program.cs. /// - internal const int BurstCapacity = 2; + internal const int BurstCapacity = 10; /// /// Replenishment interval — one token is released every this many milliseconds. @@ -259,6 +273,7 @@ internal static void ResetStaticStateForTesting() _revalidationFlights.Clear(); _sidecarCache.Clear(); Interlocked.Exchange(ref _currentCacheSize, 0); + Interlocked.Exchange(ref _evictionInProgress, 0); _cacheSizeInitialized = false; OutboundBudget.ResetForTesting(); } @@ -642,9 +657,11 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord using var response = await SendTileRequestAsync(tileUrl); if (response == null) { - _logger.LogWarning("Tile request was rejected for URL: {TileUrl}", TileProviderCatalog.RedactApiKey(tileUrl)); - retryCount--; - continue; + // Budget exhaustion means the system is at capacity — retrying is futile + // and would only add latency (up to AcquireTimeout per attempt). + _logger.LogWarning("Outbound budget exhausted, aborting fetch for: {TileUrl}", + TileProviderCatalog.RedactApiKey(tileUrl)); + break; } if (response.IsSuccessStatusCode) @@ -710,10 +727,21 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord .FirstOrDefaultAsync(t => t.Zoom == zoom && t.X == x && t.Y == y); if (existingMetadata == null) { - // If adding a new tile would exceed the cache limit in Gigabytes, evict tiles. + // If adding a new tile would exceed the cache limit, evict tiles. + // Coalesce: only one eviction runs at a time; concurrent callers skip. if ((Interlocked.Read(ref _currentCacheSize) + (tileData?.Length ?? 0)) > (_maxCacheSizeInMB * 1024L * 1024L)) { - await EvictDbTilesAsync(); + if (Interlocked.CompareExchange(ref _evictionInProgress, 1, 0) == 0) + { + try + { + await EvictDbTilesAsync(); + } + finally + { + Interlocked.Exchange(ref _evictionInProgress, 0); + } + } } var tileMetadata = new TileCacheMetadata @@ -902,22 +930,23 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord } } - // Fast path: tile is not expired — serve from cache + // Fast path: tile is not expired — serve from cache. + // No lock needed for reads: if a concurrent eviction/purge deletes the file + // between File.Exists and ReadAllBytesAsync, the IOException is caught and + // treated as a cache miss (falls through to re-fetch). if (!isExpired) { byte[]? cachedTileData = null; - await _cacheLock.WaitAsync(); try { - // Serialize file reads with purge/write operations. if (File.Exists(tileFilePath)) { cachedTileData = await File.ReadAllBytesAsync(tileFilePath); } } - finally + catch (IOException) { - _cacheLock.Release(); + // File deleted by concurrent eviction/purge — treat as cache miss. } if (cachedTileData != null) return cachedTileData; @@ -947,9 +976,9 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord } } - // Graceful degradation: serve stale cached tile if re-validation failed + // Graceful degradation: serve stale cached tile if re-validation failed. + // No lock needed for reads (see fast-path comment above). byte[]? staleTileData = null; - await _cacheLock.WaitAsync(); try { if (File.Exists(tileFilePath)) @@ -957,9 +986,9 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord staleTileData = await File.ReadAllBytesAsync(tileFilePath); } } - finally + catch (IOException) { - _cacheLock.Release(); + // File deleted by concurrent eviction/purge — treat as cache miss. } if (staleTileData != null) return staleTileData; @@ -975,9 +1004,8 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord _logger.LogDebug("Tile not in cache. Fetching from: {TileUrl}", TileProviderCatalog.RedactApiKey(tileUrl)); await CacheTileAsync(tileUrl, zoomLevel, xCoordinate, yCoordinate); - // After fetching, read the file while holding the lock to prevent race with eviction. + // After fetching, read the file. No lock needed for reads (see fast-path comment above). byte[]? fetchedTileData = null; - await _cacheLock.WaitAsync(); try { if (File.Exists(tileFilePath)) @@ -985,9 +1013,9 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord fetchedTileData = await File.ReadAllBytesAsync(tileFilePath); } } - finally + catch (IOException) { - _cacheLock.Release(); + // File deleted by concurrent eviction/purge — treat as cache miss. } return fetchedTileData; @@ -1223,11 +1251,17 @@ private async Task UpdateTileAfterRevalidationScopedAsync(ApplicationDbContext d /// /// Evicts the least recently used tiles (in batches) from the database and file system to free up cache space. + /// Uses its own to avoid lifecycle issues with the per-request DbContext. + /// Guarded by to prevent concurrent eviction runs. /// private async Task EvictDbTilesAsync() { + // Use a dedicated scope so eviction is independent of the calling request's DbContext lifecycle. + using var scope = _serviceScopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + // Retrieve a batch of the least recently accessed tiles. - var tilesToEvict = await _dbContext.TileCacheMetadata + var tilesToEvict = await dbContext.TileCacheMetadata .OrderBy(t => t.LastAccessed) .Take(LRU_TO_EVICT) // Adjust the eviction batch size as needed. .ToListAsync(); @@ -1241,14 +1275,22 @@ private async Task EvictDbTilesAsync() foreach (var tile in tilesToEvict) { - _dbContext.TileCacheMetadata.Remove(tile); + dbContext.TileCacheMetadata.Remove(tile); totalEvictedSize += tile.Size; filePaths.Add(Path.Combine(_cacheDirectory, $"{tile.Zoom}_{tile.X}_{tile.Y}.png")); } - await _dbContext.SaveChangesAsync(); - // Decrement after successful commit to keep _currentCacheSize consistent with DB reality. - Interlocked.Add(ref _currentCacheSize, -totalEvictedSize); + try + { + await dbContext.SaveChangesAsync(); + // Decrement after successful commit to keep _currentCacheSize consistent with DB reality. + Interlocked.Add(ref _currentCacheSize, -totalEvictedSize); + } + catch (DbUpdateConcurrencyException ex) + { + _logger.LogWarning(ex, "Eviction encountered concurrency conflict — tiles may have been evicted by another instance"); + return; // Don't decrement _currentCacheSize; rows were not deleted. + } // Phase 2: Delete files (best-effort, after DB commit succeeded). foreach (var tileFilePath in filePaths) diff --git a/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs b/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs index 3204eb0e..3c6b5a32 100644 --- a/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs +++ b/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs @@ -689,7 +689,7 @@ public async Task OutboundBudget_AcquireAsync_GrantsTokensUpToBurstCapacity() { TileCacheService.OutboundBudget.ResetForTesting(); - // Burst capacity is 2 — first 2 should succeed immediately. + // Burst capacity is 10 — first 10 should succeed immediately. for (int i = 0; i < TileCacheService.OutboundBudget.BurstCapacity; i++) { var acquired = await TileCacheService.OutboundBudget.AcquireAsync(); From 3f1040c4989312c366b85ac15624a5d6686ae228 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 22 Mar 2026 21:45:15 +0200 Subject: [PATCH 10/18] Fix review pass 3: lock contention, purge ordering, budget retries, test robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MEDIUM: Split 304 path by zoom level — zoom >= 9 reads without lock (no sidecar write needed), consistent with other lock-free read paths. Zoom < 9 retains lock for atomic sidecar write + file read. - MEDIUM: PurgeAllCacheAsync now uses DB-first ordering (consistent with EvictDbTilesAsync) — batch DB deletions commit before file deletes. If DB fails, no files are deleted and cache stays consistent. - MEDIUM: Retry loop acquires outbound budget token only on first attempt. Retries on HTTP 5xx reuse the already-consumed token via skipBudget parameter, preventing upstream failures from exhausting burst budget. - MEDIUM: TilesControllerTests clears static rate limit caches in constructor and uses deterministic IPs instead of Random(). - MEDIUM: SingleScopeFactory creates independent DbContext per scope sharing the same in-memory DB name, mirroring production behavior where scoped contexts have separate change trackers. --- Services/TileCacheService.cs | 167 +++++++++++------- .../Controllers/TilesControllerTests.cs | 28 +-- .../Services/TileCacheServiceTests.cs | 62 +++++-- 3 files changed, 171 insertions(+), 86 deletions(-) diff --git a/Services/TileCacheService.cs b/Services/TileCacheService.cs index 7f3e86e9..d83ef48e 100644 --- a/Services/TileCacheService.cs +++ b/Services/TileCacheService.cs @@ -400,11 +400,13 @@ public string GetCacheDirectory() /// Accepts an optional delegate for customizing request headers (e.g., conditional headers). /// private async Task SendTileRequestCoreAsync(string tileUrl, - Action? configureRequest = null) + Action? configureRequest = null, bool skipBudget = false) { // Acquire an outbound request token. If the budget is exhausted, return null // so callers can gracefully degrade (serve stale cache or return 503). - if (!await OutboundBudget.AcquireAsync().ConfigureAwait(false)) + // skipBudget is true on retries — the budget was already acquired on the first attempt, + // so retries should not consume additional tokens. + if (!skipBudget && !await OutboundBudget.AcquireAsync().ConfigureAwait(false)) { _logger.LogWarning( "Outbound request budget exhausted — throttling upstream request for {TileUrl}", @@ -476,9 +478,11 @@ public string GetCacheDirectory() /// Sends a tile request without conditional headers. /// Sets the Referer header from the current HTTP request to comply with OSM's tile usage policy. /// - private Task SendTileRequestAsync(string tileUrl) + /// The upstream tile URL. + /// If true, skips outbound budget acquisition (used on retries). + private Task SendTileRequestAsync(string tileUrl, bool skipBudget = false) { - return SendTileRequestCoreAsync(tileUrl); + return SendTileRequestCoreAsync(tileUrl, skipBudget: skipBudget); } /// @@ -651,10 +655,14 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord string? etag = null; DateTime? lastModifiedUpstream = null; DateTime? expiresAtUtc = null; + bool budgetAcquired = false; while (retryCount > 0) { - using var response = await SendTileRequestAsync(tileUrl); + // On retries, skip budget acquisition — the token was already consumed + // on the first attempt. This prevents HTTP 5xx retries from exhausting + // the entire burst budget under upstream failures. + using var response = await SendTileRequestAsync(tileUrl, skipBudget: budgetAcquired); if (response == null) { // Budget exhaustion means the system is at capacity — retrying is futile @@ -663,6 +671,7 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord TileProviderCatalog.RedactApiKey(tileUrl)); break; } + budgetAcquired = true; if (response.IsSuccessStatusCode) { @@ -1064,13 +1073,13 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord _logger.LogDebug("Tile {TileKey} re-validated (304 Not Modified)", tileKey); - // Read cached file (and write sidecar for zoom 0-8) under the same lock - // to prevent a concurrent purge from deleting the sidecar between write and read. byte[]? data = null; - await _cacheLock.WaitAsync(); - try + if (zoom < 9) { - if (zoom < 9) + // Sidecar write + file read under the same lock to prevent a concurrent + // purge from deleting the sidecar between write and read (TOCTOU). + await _cacheLock.WaitAsync(); + try { WriteSidecarMetadata(tileFilePath, new TileSidecarMetadata { @@ -1078,16 +1087,32 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord LastModifiedUpstream = lastModified, ExpiresAtUtc = newExpiry }); - } - if (File.Exists(tileFilePath)) + if (File.Exists(tileFilePath)) + { + data = await File.ReadAllBytesAsync(tileFilePath); + } + } + finally { - data = await File.ReadAllBytesAsync(tileFilePath); + _cacheLock.Release(); } } - finally + else { - _cacheLock.Release(); + // zoom >= 9: no sidecar write needed — lock-free read + // (consistent with other read paths in RetrieveTileAsync). + try + { + if (File.Exists(tileFilePath)) + { + data = await File.ReadAllBytesAsync(tileFilePath); + } + } + catch (IOException) + { + // File deleted by concurrent eviction/purge — treat as cache miss. + } } return data; @@ -1392,7 +1417,12 @@ public async Task PurgeAllCacheAsync() const int batchSize = 300; // Adjustable batch size for optimal performance const int maxRetries = 3; // Max number of retries const int delayBetweenRetries = 1000; // Delay between retries in milliseconds - var filesToDelete = new List(); + + // Phase 1: Collect files and their DB metadata into batches. + // Phase 2: Commit DB deletions first (consistent with EvictDbTilesAsync ordering). + // Phase 3: Delete files from disk after DB commit succeeds. + // If DB commit fails, no files are deleted — cache stays consistent. + var batch = new List<(TileCacheMetadata? Meta, string FilePath, long FileSize)>(); foreach (var file in Directory.EnumerateFiles(_cacheDirectory, "*.png")) { @@ -1403,48 +1433,14 @@ public async Task PurgeAllCacheAsync() .Where(t => t.TileFilePath == file) .FirstOrDefaultAsync(); - long fileSize = new FileInfo(file).Length; - - if (File.Exists(file)) - { - await _cacheLock.WaitAsync(); - try - { - // Serialize file deletes with cache reads/writes. - File.Delete(file); // Delete the file from disk - Interlocked.Add(ref _currentCacheSize, -fileSize); // Update cache size tracker - } - finally - { - _cacheLock.Release(); - } - - if (fileToPurge != null) - { - _logger.LogInformation("Marking file {File} for deletion in DB.", file); - // Add the entity to the deletion list. - filesToDelete.Add(fileToPurge); - } - else - { - _logger.LogWarning("No DB record found for file {File}.", file); - } - } - else - { - _logger.LogWarning("File not found for deletion: {File}", file); - } + long fileSize = File.Exists(file) ? new FileInfo(file).Length : 0; + batch.Add((fileToPurge, file, fileSize)); - // Commit in batches - if (filesToDelete.Count >= batchSize) + // Commit and delete in batches. + if (batch.Count >= batchSize) { - await RetryOperationAsync(async () => - { - dbContext.TileCacheMetadata.RemoveRange(filesToDelete); - var affectedRows = await dbContext.SaveChangesAsync(); - _logger.LogInformation("Batch commit completed. Rows affected: {Rows}", affectedRows); - filesToDelete.Clear(); - }, maxRetries, delayBetweenRetries); + await PurgeBatchAsync(dbContext, batch, maxRetries, delayBetweenRetries); + batch.Clear(); } } catch (Exception e) @@ -1453,15 +1449,11 @@ await RetryOperationAsync(async () => } } - // Commit any remaining entries if the batch size was not reached - if (filesToDelete.Any()) + // Commit any remaining entries if the batch size was not reached. + if (batch.Any()) { - await RetryOperationAsync(async () => - { - dbContext.TileCacheMetadata.RemoveRange(filesToDelete); - var affectedRows = await dbContext.SaveChangesAsync(); - _logger.LogInformation("Final commit completed. Rows affected: {Rows}", affectedRows); - }, maxRetries, delayBetweenRetries); + await PurgeBatchAsync(dbContext, batch, maxRetries, delayBetweenRetries); + batch.Clear(); } // Clean up orphan DB records (records without corresponding files on disk) @@ -1512,6 +1504,53 @@ private void CleanupSidecarFiles() } } + /// + /// Processes a purge batch: commits DB deletions first, then deletes files from disk. + /// Consistent with ordering — if DB commit fails, + /// no files are deleted and cache stays consistent. + /// + private async Task PurgeBatchAsync(ApplicationDbContext dbContext, + List<(TileCacheMetadata? Meta, string FilePath, long FileSize)> batch, + int maxRetries, int delayBetweenRetries) + { + // Phase 1: Commit DB deletions first. + var dbEntries = batch.Where(b => b.Meta != null).Select(b => b.Meta!).ToList(); + if (dbEntries.Any()) + { + await RetryOperationAsync(async () => + { + dbContext.TileCacheMetadata.RemoveRange(dbEntries); + var affectedRows = await dbContext.SaveChangesAsync(); + _logger.LogInformation("Purge batch DB commit completed. Rows affected: {Rows}", affectedRows); + }, maxRetries, delayBetweenRetries); + } + + // Phase 2: Delete files from disk (best-effort, after DB commit succeeded). + foreach (var (_, filePath, fileSize) in batch) + { + try + { + await _cacheLock.WaitAsync(); + try + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + Interlocked.Add(ref _currentCacheSize, -fileSize); + } + } + finally + { + _cacheLock.Release(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete purged file: {File}", filePath); + } + } + } + private async Task RetryOperationAsync(Func operation, int maxRetries, int delayBetweenRetries) { int attempt = 0; diff --git a/tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs b/tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs index 17cc0897..49aa184d 100644 --- a/tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs +++ b/tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs @@ -24,6 +24,16 @@ namespace Wayfarer.Tests.Controllers; /// public class TilesControllerTests : TestBase { + /// + /// Clears static rate limit caches before each test to prevent cross-test interference. + /// xUnit creates a new test class instance per test, so this runs before every test method. + /// + public TilesControllerTests() + { + TilesController.RateLimitCache.Clear(); + TilesController.AuthRateLimitCache.Clear(); + } + [Fact] public async Task GetTile_UnauthorizedWithoutReferer() { @@ -183,9 +193,8 @@ public async Task GetTile_RateLimitExceeded_Returns429() var controller = BuildController(cacheDir: cacheDir, settingsService: settingsService); controller.ControllerContext.HttpContext.Request.Headers["Referer"] = "http://example.com/page"; - // Use a unique IP for this test to avoid interference from other tests - var uniqueIp = $"192.168.99.{new Random().Next(1, 255)}"; - controller.ControllerContext.HttpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Parse(uniqueIp); + // Deterministic IP — caches are cleared in constructor so no cross-test interference. + controller.ControllerContext.HttpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("192.168.99.1"); try { @@ -222,9 +231,8 @@ public async Task GetTile_RateLimitDisabled_NoLimit() var controller = BuildController(cacheDir: cacheDir, settingsService: settingsService); controller.ControllerContext.HttpContext.Request.Headers["Referer"] = "http://example.com/page"; - // Use a unique IP for this test - var uniqueIp = $"192.168.88.{new Random().Next(1, 255)}"; - controller.ControllerContext.HttpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Parse(uniqueIp); + // Deterministic IP — caches are cleared in constructor so no cross-test interference. + controller.ControllerContext.HttpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("192.168.88.1"); try { @@ -259,10 +267,10 @@ public async Task GetTile_RespectXForwardedFor() var controller = BuildController(cacheDir: cacheDir, settingsService: settingsService); controller.ControllerContext.HttpContext.Request.Headers["Referer"] = "http://example.com/page"; - // Use unique IPs for this test - var proxyIp = $"10.0.0.{new Random().Next(1, 255)}"; - var clientIp1 = $"203.0.113.{new Random().Next(1, 127)}"; - var clientIp2 = $"203.0.113.{new Random().Next(128, 255)}"; + // Deterministic IPs — caches are cleared in constructor so no cross-test interference. + var proxyIp = "10.0.0.1"; + var clientIp1 = "203.0.113.10"; + var clientIp2 = "203.0.113.20"; controller.ControllerContext.HttpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Parse(proxyIp); diff --git a/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs b/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs index 3c6b5a32..ecbca317 100644 --- a/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs +++ b/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs @@ -4,6 +4,8 @@ using System.Linq; using System.Text.Json; using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; @@ -74,17 +76,18 @@ public async Task PurgeAllCacheAsync_RemovesFilesAndMetadata() public async Task CacheTileAsync_EvictsLru_WhenCacheOverLimit() { using var dir = new TempDir(); - var db = CreateDbContext(); + var (db, dbName) = CreateNamedDbContext(); var handler = new SizedTileHandler(600_000); // ~0.57 MB tiles - var service = CreateService(db, dir.Path, handler, maxCacheMb: 1); + var service = CreateService(db, dir.Path, handler, maxCacheMb: 1, dbName: dbName); await service.CacheTileAsync("http://tiles/9/1/1.png", "9", "1", "1"); // fits await service.CacheTileAsync("http://tiles/9/1/2.png", "9", "1", "2"); // triggers eviction of oldest - Assert.Equal(1, db.TileCacheMetadata.Count()); - var meta = db.TileCacheMetadata.Single(); - Assert.Equal(1, meta.X); - Assert.Equal(2, meta.Y); + // Reload from DB to see scoped eviction changes (eviction uses its own DbContext). + var remaining = db.TileCacheMetadata.ToList(); + Assert.Single(remaining); + Assert.Equal(1, remaining[0].X); + Assert.Equal(2, remaining[0].Y); } [Fact] @@ -502,7 +505,23 @@ public async Task CacheTileAsync_StoresSidecarWithLastModified_ForLowZoom() /// AddHttpClient registration in Program.cs. Accept and AcceptLanguage headers /// are omitted because no current test exercises content negotiation. /// - private TileCacheService CreateService(ApplicationDbContext db, string cacheDir, HttpMessageHandler? handler = null, int maxCacheMb = 10, IHttpContextAccessor? httpContextAccessor = null, string contactEmail = "test@example.com") + /// + /// Creates an with a known database name, so that + /// can create independent DbContext instances that + /// share the same in-memory database store. + /// + private (ApplicationDbContext Db, string DbName) CreateNamedDbContext() + { + var dbName = Guid.NewGuid().ToString(); + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(dbName) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + var db = new ApplicationDbContext(options, new ServiceCollection().BuildServiceProvider()); + return (db, dbName); + } + + private TileCacheService CreateService(ApplicationDbContext db, string cacheDir, HttpMessageHandler? handler = null, int maxCacheMb = 10, IHttpContextAccessor? httpContextAccessor = null, string contactEmail = "test@example.com", string? dbName = null) { var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -524,8 +543,19 @@ private TileCacheService CreateService(ApplicationDbContext db, string cacheDir, // Reset static state so tests don't interfere with each other. TileCacheService.ResetStaticStateForTesting(); + // Create a scope factory that returns independent DbContext instances sharing + // the same in-memory database by name. This mirrors production behavior where + // each scope gets its own DbContext with a separate change tracker. var appSettings = new StubSettingsService(maxCacheMb); - var scopeFactory = new SingleScopeFactory(db); + var scopeFactory = dbName != null + ? new SingleScopeFactory(() => + new ApplicationDbContext( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(dbName) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options, + new ServiceCollection().BuildServiceProvider())) + : new SingleScopeFactory(() => db); return new TileCacheService( NullLogger.Instance, config, @@ -744,16 +774,24 @@ private sealed class StubSettingsService : IApplicationSettingsService public void RefreshSettings() { } } + /// + /// Creates independent instances per scope, sharing the + /// same in-memory database by name. This mirrors production behavior where each scope gets + /// its own DbContext with a separate change tracker, preventing tests from masking + /// change-tracker conflicts between the request context and scoped contexts. + /// private sealed class SingleScopeFactory : IServiceScopeFactory { - private readonly ApplicationDbContext _db; - public SingleScopeFactory(ApplicationDbContext db) => _db = db; + private readonly Func _dbFactory; + + public SingleScopeFactory(Func dbFactory) => _dbFactory = dbFactory; public IServiceScope CreateScope() { + var db = _dbFactory(); var provider = new ServiceCollection() - .AddSingleton(_db) - .AddSingleton(_db) + .AddSingleton(db) + .AddSingleton(db) .BuildServiceProvider(); return new SimpleScope(provider); } From d732c4bd6d0a2f3fd2a74b84e9f8455f1d2b0776 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 22 Mar 2026 22:00:33 +0200 Subject: [PATCH 11/18] Fix review pass 4: eviction concurrency, purge ordering, orphan query, entity tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HIGH: PurgeLRUCacheAsync restructured to DB-first ordering (consistent with EvictDbTilesAsync) — commit DB deletions before file deletes. Removed broken explicit transaction that caused retry failures on CommitAsync. - HIGH: PurgeAllCacheAsync orphan query replaced File.Exists (untranslatable to SQL) with client-side HashSet filtering of existing disk files. - MEDIUM: CacheTileAsync concurrency retry uses entry.ReloadAsync() instead of ToObject() to avoid creating an untracked entity that conflicts with the original tracked instance on the next Update() call. - MEDIUM: EvictDbTilesAsync loads candidates with AsNoTracking + re-fetches by ID before delete, so a concurrent LastAccessed update doesn't cause stale RowVersion conflicts that permanently block eviction. - MEDIUM: StopReplenisher captures CTS in local variable before assigning to Lazy lambda, preventing shared CTS between instances on rapid sequential calls. --- Services/TileCacheService.cs | 120 ++++++++++++++++++----------------- 1 file changed, 62 insertions(+), 58 deletions(-) diff --git a/Services/TileCacheService.cs b/Services/TileCacheService.cs index d83ef48e..082827b1 100644 --- a/Services/TileCacheService.cs +++ b/Services/TileCacheService.cs @@ -215,9 +215,10 @@ private static void StopReplenisher() { var oldCts = _replenisherCts; oldCts.Cancel(); - _replenisherCts = new CancellationTokenSource(); + var newCts = new CancellationTokenSource(); + _replenisherCts = newCts; _replenisher = new Lazy( - () => StartReplenisher(_replenisherCts.Token), + () => StartReplenisher(newCts.Token), LazyThreadSafetyMode.ExecutionAndPublication); } @@ -803,7 +804,9 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord _logger.LogWarning(ex, "Concurrency conflict detected while updating tile metadata. Attempt {Attempt}.", attempts); - // Reload the entity from the database. + // Reload the tracked entity from the database to resolve the conflict. + // Using ReloadAsync instead of ToObject() to avoid creating an untracked + // entity that would conflict with the original tracked instance. var entry = ex.Entries.Single(); var databaseValues = await entry.GetDatabaseValuesAsync(); if (databaseValues == null) @@ -812,8 +815,8 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord return; } - // Update the local copy with database values and reapply our changes. - existingMetadata = (TileCacheMetadata)databaseValues.ToObject(); + await entry.ReloadAsync(); + existingMetadata = (TileCacheMetadata)entry.Entity; existingMetadata.Size = tileData?.Length ?? 0; existingMetadata.LastAccessed = DateTime.UtcNow; existingMetadata.ETag = etag; @@ -1285,35 +1288,42 @@ private async Task EvictDbTilesAsync() using var scope = _serviceScopeFactory.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - // Retrieve a batch of the least recently accessed tiles. + // Retrieve a batch of the least recently accessed tile IDs and sizes. + // AsNoTracking avoids loading RowVersion into the change tracker, so a concurrent + // LastAccessed update won't cause DbUpdateConcurrencyException on our delete. var tilesToEvict = await dbContext.TileCacheMetadata .OrderBy(t => t.LastAccessed) - .Take(LRU_TO_EVICT) // Adjust the eviction batch size as needed. + .Take(LRU_TO_EVICT) + .AsNoTracking() + .Select(t => new { t.Id, t.Zoom, t.X, t.Y, t.Size }) .ToListAsync(); // Phase 1: Commit DB deletions first. - // If SaveChangesAsync fails, no files are deleted — cache stays consistent. + // If the delete fails, no files are deleted — cache stays consistent. // If it succeeds but file deletion later fails, orphaned files are harmless // and self-correcting (next cache write for that tile overwrites them). - long totalEvictedSize = 0; - var filePaths = new List(tilesToEvict.Count); - - foreach (var tile in tilesToEvict) - { - dbContext.TileCacheMetadata.Remove(tile); - totalEvictedSize += tile.Size; - filePaths.Add(Path.Combine(_cacheDirectory, $"{tile.Zoom}_{tile.X}_{tile.Y}.png")); - } + long totalEvictedSize = tilesToEvict.Sum(t => (long)t.Size); + var filePaths = tilesToEvict + .Select(t => Path.Combine(_cacheDirectory, $"{t.Zoom}_{t.X}_{t.Y}.png")) + .ToList(); + var tileIds = tilesToEvict.Select(t => t.Id).ToList(); try { + // Re-fetch by ID and delete. The fresh fetch picks up the current RowVersion, + // so there is no stale-token conflict even if LastAccessed changed concurrently. + var toDelete = await dbContext.TileCacheMetadata + .Where(t => tileIds.Contains(t.Id)) + .ToListAsync(); + dbContext.TileCacheMetadata.RemoveRange(toDelete); await dbContext.SaveChangesAsync(); // Decrement after successful commit to keep _currentCacheSize consistent with DB reality. Interlocked.Add(ref _currentCacheSize, -totalEvictedSize); + _logger.LogInformation("Evicted {Count} tiles from database.", toDelete.Count); } - catch (DbUpdateConcurrencyException ex) + catch (Exception ex) { - _logger.LogWarning(ex, "Eviction encountered concurrency conflict — tiles may have been evicted by another instance"); + _logger.LogWarning(ex, "Eviction DB delete failed — tiles were not evicted"); return; // Don't decrement _currentCacheSize; rows were not deleted. } @@ -1456,10 +1466,15 @@ public async Task PurgeAllCacheAsync() batch.Clear(); } - // Clean up orphan DB records (records without corresponding files on disk) - var orphanRecords = await dbContext.TileCacheMetadata - .Where(t => !File.Exists(t.TileFilePath)) - .ToListAsync(); + // Clean up orphan DB records (records without corresponding files on disk). + // File.Exists cannot be translated to SQL, so load all records and filter client-side. + // Use a HashSet of existing files for O(1) lookups instead of per-record disk I/O. + var existingFiles = new HashSet( + Directory.EnumerateFiles(_cacheDirectory, "*.png")); + var allRecords = await dbContext.TileCacheMetadata.ToListAsync(); + var orphanRecords = allRecords + .Where(t => !existingFiles.Contains(t.TileFilePath)) + .ToList(); if (orphanRecords.Any()) { @@ -1579,60 +1594,49 @@ private async Task RetryOperationAsync(Func operation, int maxRetries, int /// /// Purges all LRU tile cache (zoom levels >= 9) from both file system and database. + /// Uses DB-first ordering consistent with : + /// commit DB deletions first, then delete files. If DB fails, no files are deleted. + /// No explicit transaction needed — + /// uses an implicit transaction. /// public async Task PurgeLRUCacheAsync() { using var scope = _serviceScopeFactory.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - using var transaction = await dbContext.Database.BeginTransactionAsync(); - var lruCache = await dbContext.TileCacheMetadata .Where(file => file.Zoom >= 9) - .AsTracking() .ToListAsync(); - var recordsToDelete = new List(); + if (!lruCache.Any()) return; + + // Collect file paths and sizes before DB deletion. + var fileInfo = lruCache + .Select(t => (FilePath: t.TileFilePath, Size: (long)t.Size)) + .ToList(); - foreach (var file in lruCache) + // Phase 1: Commit DB deletions first. + // If this fails, no files are deleted — cache stays consistent. + await RetryOperationAsync(async () => + { + dbContext.TileCacheMetadata.RemoveRange(lruCache); + await dbContext.SaveChangesAsync(); + }, 3, 1000); + + _logger.LogInformation("LRU purge: {Count} DB records deleted.", lruCache.Count); + + // Phase 2: Delete files from disk (best-effort, after DB commit succeeded). + foreach (var (filePath, fileSize) in fileInfo) { try { - if (File.Exists(file.TileFilePath)) - { - // Use RetryOperationAsync for file deletion logic - await RetryOperationAsync(() => - { - return DeleteCacheFileAsync(file.TileFilePath, file.Size); - }, 3, 500); // 3 retries, 500ms delay between retries - } - else - { - _logger.LogWarning("File not found for deletion: {File}", file.TileFilePath); - } - // Always mark DB record for deletion, regardless of whether file existed - recordsToDelete.Add(file); + await DeleteCacheFileAsync(filePath, fileSize); } catch (Exception e) { - _logger.LogError(e, "Error processing file {File}", file.TileFilePath); + _logger.LogError(e, "Error deleting LRU cache file {File}", filePath); } } - - if (recordsToDelete.Any()) - { - // Use RetryOperationAsync for database save logic - await RetryOperationAsync(async () => - { - dbContext.TileCacheMetadata.RemoveRange(recordsToDelete); - await dbContext.SaveChangesAsync(); - await transaction.CommitAsync(); - }, 3, 1000); // 3 retries, 1000ms delay between retries - } - else - { - await transaction.RollbackAsync(); - } } /// From e2e4e623866ad81b9af38e8f02163d67c2aa162a Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 22 Mar 2026 22:07:33 +0200 Subject: [PATCH 12/18] Fix review pass 5: PurgeBatchAsync cache size drift for zoom 0-8 tiles - MEDIUM: PurgeBatchAsync only decrements _currentCacheSize for DB-tracked tiles (zoom >= 9, Meta != null). Previously decremented for all files including zoom 0-8, driving the counter negative after purge-all and permanently disabling eviction until app restart. --- Services/TileCacheService.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Services/TileCacheService.cs b/Services/TileCacheService.cs index 082827b1..1209671e 100644 --- a/Services/TileCacheService.cs +++ b/Services/TileCacheService.cs @@ -1541,7 +1541,10 @@ await RetryOperationAsync(async () => } // Phase 2: Delete files from disk (best-effort, after DB commit succeeded). - foreach (var (_, filePath, fileSize) in batch) + // Only decrement _currentCacheSize for DB-tracked tiles (zoom >= 9, Meta != null). + // Zoom 0-8 tiles are not tracked in _currentCacheSize, so decrementing them + // would drive the counter negative and permanently disable eviction. + foreach (var (meta, filePath, fileSize) in batch) { try { @@ -1551,7 +1554,10 @@ await RetryOperationAsync(async () => if (File.Exists(filePath)) { File.Delete(filePath); - Interlocked.Add(ref _currentCacheSize, -fileSize); + if (meta != null) + { + Interlocked.Add(ref _currentCacheSize, -meta.Size); + } } } finally From 04ca005e3d4e3e0aaf087f9725100b47ce5ddd9e Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 22 Mar 2026 22:32:36 +0200 Subject: [PATCH 13/18] Fix review pass 6: purge retry safety, sliding-window accuracy, concurrency guards - PurgeBatchAsync/PurgeLRUCacheAsync: clear change tracker + re-fetch by ID inside retry lambda to prevent entity tracking conflicts on retry - PurgeAllCacheAsync orphan query: AsNoTracking + project only Id/TileFilePath to reduce memory footprint from full entity load - RateLimitHelper: swap rotation order to atomic capture-then-assign, eliminating undercount gap where concurrent increments were lost - OutboundBudget: add lock to StopReplenisher preventing concurrent CTS/Lazy interleaving that could orphan a replenisher - Document _dbContext lifecycle safety and Referer check design rationale --- Areas/Public/Controllers/TilesController.cs | 5 ++ Services/RateLimitHelper.cs | 22 ++--- Services/TileCacheService.cs | 97 ++++++++++++++++----- 3 files changed, 89 insertions(+), 35 deletions(-) diff --git a/Areas/Public/Controllers/TilesController.cs b/Areas/Public/Controllers/TilesController.cs index 8d77e21e..c75076a1 100644 --- a/Areas/Public/Controllers/TilesController.cs +++ b/Areas/Public/Controllers/TilesController.cs @@ -61,6 +61,11 @@ public TilesController(ILogger logger, TileCacheService tileCac public async Task GetTile(int z, int x, int y) { // Validate the referer header to prevent third-party exploitation. + // Intentionally restrictive: rejects requests without a same-origin Referer. + // Acceptable because: mobile app fetches tiles directly from OSM (not this proxy), + // embedded maps (iframes) work because tile requests originate same-origin, + // and non-browser API clients are not expected to use this endpoint. + // The rate limiter is the primary abuse defense; this is an additional deterrent. string? refererValue = Request.Headers["Referer"].ToString(); if (string.IsNullOrEmpty(refererValue) || !IsValidReferer(refererValue)) { diff --git a/Services/RateLimitHelper.cs b/Services/RateLimitHelper.cs index e806a64e..e4106d49 100644 --- a/Services/RateLimitHelper.cs +++ b/Services/RateLimitHelper.cs @@ -8,12 +8,10 @@ namespace Wayfarer.Services; /// Shared rate limiting utility using a sliding-window counter approximation with 1-minute windows. /// Prevents boundary-batching attacks where a burst at the end of one window plus the start of the /// next could double the effective limit. Thread-safe: uses atomic operations to minimize race conditions. -/// Note: during window rotation there are two sources of jitter: -/// 1. A thread can increment the old window's count after the CAS but before the reset, causing an -/// undercount by the number of concurrent threads in that window (typically 1-2, up to thread count). -/// 2. The weighted count reads _windowStartTicks, _expirationTicks, and _prevCount non-atomically, -/// so during rotation the weight can be skewed by up to prevCount × ~0.5 in the worst case. -/// Both effects are transient (lasting only the rotation instant) and acceptable for rate limiting. +/// Note: during window rotation, the weighted count reads _windowStartTicks, _expirationTicks, +/// and _prevCount non-atomically, so during rotation the weight can be skewed by up to +/// prevCount × ~0.5 in the worst case. This is transient (lasting only the rotation instant) +/// and acceptable for rate limiting. /// Used by and /// . /// @@ -54,8 +52,8 @@ public RateLimitEntry(long expirationTicks) /// /// Atomically increments the counter and returns the weighted sliding-window count. - /// If the window has expired, rotates: copies current count to previous, resets current, - /// and updates expiration using compare-and-swap to avoid TOCTOU race conditions. + /// If the window has expired, rotates: atomically captures and zeroes the current count, + /// moves the captured value to previous, and updates expiration using compare-and-swap. /// The returned count is: prevCount * (1 - elapsed/windowSize) + currentCount, /// which smoothly decays the previous window's contribution over the new window. /// @@ -69,9 +67,11 @@ public int IncrementAndGet(long currentTicks, long newExpirationTicks) { if (Interlocked.CompareExchange(ref _expirationTicks, newExpirationTicks, currentExpiration) == currentExpiration) { - // Won the CAS — rotate window: current becomes previous, reset current. - Interlocked.Exchange(ref _prevCount, Volatile.Read(ref _count)); - Interlocked.Exchange(ref _count, 0); + // Won the CAS — rotate window: atomically capture and zero current count, + // then move captured value to previous. This eliminates the gap where + // concurrent increments could be lost between read and reset. + var captured = Interlocked.Exchange(ref _count, 0); + Interlocked.Exchange(ref _prevCount, captured); Interlocked.Exchange(ref _windowStartTicks, currentExpiration); } } diff --git a/Services/TileCacheService.cs b/Services/TileCacheService.cs index 1209671e..8de3ba76 100644 --- a/Services/TileCacheService.cs +++ b/Services/TileCacheService.cs @@ -13,6 +13,15 @@ public class TileCacheService { private readonly ILogger _logger; + + /// + /// Request-scoped database context injected via constructor. + /// Safe to use directly in CacheTileAsync/RetrieveTileAsync because TileCacheService is + /// Transient (one instance per injection via AddHttpClient) and DbContext is Scoped (one per + /// request). Each request gets its own TileCacheService + DbContext pair — no cross-request sharing. + /// Background operations (eviction, purge, revalidation) create their own scope via + /// to avoid disposed-context failures. + /// private readonly ApplicationDbContext _dbContext; private readonly string _cacheDirectory; private readonly HttpClient _httpClient; @@ -208,18 +217,25 @@ private static Task StartReplenisher(CancellationToken ct) /// starts on the next call. Cancels the old CTS first to stop /// the running replenisher before creating replacements — this eliminates the brief window /// where two replenishers could overlap and double-release tokens. + /// Uses to prevent concurrent calls from interleaving the + /// cancel-then-replace sequence and orphaning a replenisher. /// Does NOT dispose the old CTS — the replenisher task may still reference its token; /// abandoned CTS instances are GC'd. /// + private static readonly object _stopLock = new(); + private static void StopReplenisher() { - var oldCts = _replenisherCts; - oldCts.Cancel(); - var newCts = new CancellationTokenSource(); - _replenisherCts = newCts; - _replenisher = new Lazy( - () => StartReplenisher(newCts.Token), - LazyThreadSafetyMode.ExecutionAndPublication); + lock (_stopLock) + { + var oldCts = _replenisherCts; + oldCts.Cancel(); + var newCts = new CancellationTokenSource(); + _replenisherCts = newCts; + _replenisher = new Lazy( + () => StartReplenisher(newCts.Token), + LazyThreadSafetyMode.ExecutionAndPublication); + } } /// @@ -1467,23 +1483,34 @@ public async Task PurgeAllCacheAsync() } // Clean up orphan DB records (records without corresponding files on disk). - // File.Exists cannot be translated to SQL, so load all records and filter client-side. - // Use a HashSet of existing files for O(1) lookups instead of per-record disk I/O. + // File.Exists cannot be translated to SQL, so project only Id + TileFilePath + // with AsNoTracking to minimize memory, then filter client-side with a HashSet. var existingFiles = new HashSet( Directory.EnumerateFiles(_cacheDirectory, "*.png")); - var allRecords = await dbContext.TileCacheMetadata.ToListAsync(); - var orphanRecords = allRecords + var allPaths = await dbContext.TileCacheMetadata + .AsNoTracking() + .Select(t => new { t.Id, t.TileFilePath }) + .ToListAsync(); + var orphanIds = allPaths .Where(t => !existingFiles.Contains(t.TileFilePath)) + .Select(t => t.Id) .ToList(); - if (orphanRecords.Any()) + if (orphanIds.Any()) { - _logger.LogInformation("Found {Count} orphan DB records without files on disk.", orphanRecords.Count); + _logger.LogInformation("Found {Count} orphan DB records without files on disk.", orphanIds.Count); await RetryOperationAsync(async () => { - dbContext.TileCacheMetadata.RemoveRange(orphanRecords); - var affectedRows = await dbContext.SaveChangesAsync(); - _logger.LogInformation("Orphan records cleanup completed. Rows affected: {Rows}", affectedRows); + dbContext.ChangeTracker.Clear(); + var toDelete = await dbContext.TileCacheMetadata + .Where(t => orphanIds.Contains(t.Id)) + .ToListAsync(); + if (toDelete.Any()) + { + dbContext.TileCacheMetadata.RemoveRange(toDelete); + var affectedRows = await dbContext.SaveChangesAsync(); + _logger.LogInformation("Orphan records cleanup completed. Rows affected: {Rows}", affectedRows); + } }, maxRetries, delayBetweenRetries); } @@ -1529,14 +1556,24 @@ private async Task PurgeBatchAsync(ApplicationDbContext dbContext, int maxRetries, int delayBetweenRetries) { // Phase 1: Commit DB deletions first. - var dbEntries = batch.Where(b => b.Meta != null).Select(b => b.Meta!).ToList(); - if (dbEntries.Any()) + // Re-fetches entities by ID inside the retry lambda so that each attempt starts + // with a clean change tracker and freshly tracked entities — prevents + // InvalidOperationException from retrying RemoveRange on already-Deleted entities. + var ids = batch.Where(b => b.Meta != null).Select(b => b.Meta!.Id).ToList(); + if (ids.Any()) { await RetryOperationAsync(async () => { - dbContext.TileCacheMetadata.RemoveRange(dbEntries); - var affectedRows = await dbContext.SaveChangesAsync(); - _logger.LogInformation("Purge batch DB commit completed. Rows affected: {Rows}", affectedRows); + dbContext.ChangeTracker.Clear(); + var toDelete = await dbContext.TileCacheMetadata + .Where(t => ids.Contains(t.Id)) + .ToListAsync(); + if (toDelete.Any()) + { + dbContext.TileCacheMetadata.RemoveRange(toDelete); + var affectedRows = await dbContext.SaveChangesAsync(); + _logger.LogInformation("Purge batch DB commit completed. Rows affected: {Rows}", affectedRows); + } }, maxRetries, delayBetweenRetries); } @@ -1610,8 +1647,11 @@ public async Task PurgeLRUCacheAsync() using var scope = _serviceScopeFactory.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); + // Project only the fields needed — AsNoTracking avoids change tracker overhead. var lruCache = await dbContext.TileCacheMetadata + .AsNoTracking() .Where(file => file.Zoom >= 9) + .Select(t => new { t.Id, t.TileFilePath, t.Size }) .ToListAsync(); if (!lruCache.Any()) return; @@ -1622,11 +1662,20 @@ public async Task PurgeLRUCacheAsync() .ToList(); // Phase 1: Commit DB deletions first. - // If this fails, no files are deleted — cache stays consistent. + // Re-fetches by ID inside the retry lambda so each attempt starts with a clean + // change tracker — prevents entity tracking conflicts on retry. + var lruIds = lruCache.Select(t => t.Id).ToList(); await RetryOperationAsync(async () => { - dbContext.TileCacheMetadata.RemoveRange(lruCache); - await dbContext.SaveChangesAsync(); + dbContext.ChangeTracker.Clear(); + var toDelete = await dbContext.TileCacheMetadata + .Where(t => lruIds.Contains(t.Id)) + .ToListAsync(); + if (toDelete.Any()) + { + dbContext.TileCacheMetadata.RemoveRange(toDelete); + await dbContext.SaveChangesAsync(); + } }, 3, 1000); _logger.LogInformation("LRU purge: {Count} DB records deleted.", lruCache.Count); From 7580510ea70d0b3eff70b34055c57155156822f8 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 22 Mar 2026 22:59:01 +0200 Subject: [PATCH 14/18] Fix review pass 7: index, purge perf, lock convoy, budget timeout, rate limit hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CRITICAL: Add unique composite index on TileCacheMetadata(Zoom, X, Y) — eliminates sequential scans on every tile request hot path - CRITICAL: PurgeAllCacheAsync bulk-loads metadata in one query instead of O(N) individual DB queries per cached PNG file - HIGH: Eviction _currentCacheSize now computed from re-fetched entity sizes instead of stale projected sizes, reducing drift - HIGH: Outbound budget AcquireTimeout reduced from 10s to 3s to prevent thread pool starvation under cold-cache load; CancellationToken threaded through Send*/Cache*/Retrieve* chain to HttpContext.RequestAborted - HIGH: Eviction and purge file deletion consolidated into single lock acquisition per batch, eliminating convoy effects from per-file locking - MEDIUM: X-Forwarded-For IPs normalized (IPv4-mapped IPv6 → IPv4) to prevent aliasing from creating separate rate-limit buckets - MEDIUM: Rate limit cache hard cap (50K entries) with oldest-entry eviction prevents unbounded memory growth from sustained low-rate attacks - MEDIUM: Periodic _currentCacheSize reconciliation via RateLimitCleanupJob corrects accumulated drift from non-atomic size tracking - MEDIUM: PurgeLRUCacheAsync chunked into 1000-ID batches to prevent PostgreSQL query plan explosion from large IN clauses - MEDIUM: Sliding-window documentation corrected — worst-case jitter is up to full prevCount during rotation, not ~0.5 --- Areas/Public/Controllers/TilesController.cs | 2 +- CHANGELOG.md | 24 + Jobs/RateLimitCleanupJob.cs | 23 +- ...205010_AddTileCacheZoomXYIndex.Designer.cs | 1606 +++++++++++++++++ .../20260322205010_AddTileCacheZoomXYIndex.cs | 28 + .../ApplicationDbContextModelSnapshot.cs | 3 + Models/ApplicationDbContext.cs | 8 + Services/RateLimitHelper.cs | 67 +- Services/TileCacheService.cs | 179 +- 9 files changed, 1858 insertions(+), 82 deletions(-) create mode 100644 Migrations/20260322205010_AddTileCacheZoomXYIndex.Designer.cs create mode 100644 Migrations/20260322205010_AddTileCacheZoomXYIndex.cs diff --git a/Areas/Public/Controllers/TilesController.cs b/Areas/Public/Controllers/TilesController.cs index c75076a1..1b71f6c8 100644 --- a/Areas/Public/Controllers/TilesController.cs +++ b/Areas/Public/Controllers/TilesController.cs @@ -141,7 +141,7 @@ public async Task GetTile(int z, int x, int y) // Call the tile cache service to retrieve the tile. // The service will either return the cached tile data or (if missing) download, cache, and then return it. - var tileData = await _tileCacheService.RetrieveTileAsync(z.ToString(), x.ToString(), y.ToString(), tileUrl); + var tileData = await _tileCacheService.RetrieveTileAsync(z.ToString(), x.ToString(), y.ToString(), tileUrl, HttpContext.RequestAborted); if (tileData == null) { _logger.LogError("Tile data not found for {z}/{x}/{y}", z, x, y); diff --git a/CHANGELOG.md b/CHANGELOG.md index f9e2d981..1e6b97c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # CHANGELOG +## [1.2.19] - 2026-03-22 + +### Added +- Unique composite index on `TileCacheMetadata(Zoom, X, Y)` — eliminates sequential scans on every tile request (#204) +- Periodic tile cache size reconciliation via `RateLimitCleanupJob` — corrects `_currentCacheSize` drift from non-atomic updates every 5 minutes (#204) +- Hard cap (50K entries) on rate limit caches with oldest-entry eviction — prevents unbounded memory growth from sustained low-rate attacks (#204) +- `CancellationToken` propagation through tile request chain — requests abort when client disconnects instead of blocking threads (#204) + +### Changed +- Outbound budget `AcquireTimeout` reduced from 10s to 3s to prevent thread pool starvation under sustained cold-cache load (#204) +- `PurgeAllCacheAsync` loads all DB metadata in a single query instead of O(N) individual queries per file (#204) +- `PurgeLRUCacheAsync` now deletes in chunks of 1000 IDs to prevent PostgreSQL query plan explosion from large IN clauses (#204) +- Eviction and purge file deletion consolidated into single lock acquisition per batch, eliminating convoy effects (#204) +- `X-Forwarded-For` IP addresses normalized to canonical form — prevents IPv4/IPv6 aliasing from creating separate rate limit buckets (#204) +- Eviction `_currentCacheSize` decrement now uses re-fetched entity sizes instead of stale projected sizes (#204) + +### Fixed +- **CRITICAL:** Missing database index on hot-path tile lookup queries (`Zoom, X, Y`) — every tile request was a sequential scan (#204) +- **CRITICAL:** `PurgeAllCacheAsync` issued individual DB query per cached file — 100K files caused 100K sequential-scan queries (#204) +- **HIGH:** `_currentCacheSize` drift from eviction using pre-fetched sizes instead of actual deleted sizes (#204) +- **HIGH:** Thread pool starvation risk from 10-second outbound budget timeout under cold-cache load (#204) +- **HIGH:** Lock convoy during eviction/purge — per-file lock acquisition serialized all concurrent writes (#204) +- **MEDIUM:** Sliding-window rate limiter documentation understated worst-case jitter (up to full prevCount, not ~0.5) (#204) + ## [1.2.18] - 2026-03-22 ### Added diff --git a/Jobs/RateLimitCleanupJob.cs b/Jobs/RateLimitCleanupJob.cs index 5c10438c..09764ca4 100644 --- a/Jobs/RateLimitCleanupJob.cs +++ b/Jobs/RateLimitCleanupJob.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.DependencyInjection; using Quartz; using Wayfarer.Areas.Public.Controllers; using Wayfarer.Services; @@ -5,7 +6,8 @@ namespace Wayfarer.Jobs; /// -/// Quartz job that periodically sweeps expired entries from all in-memory rate limit caches. +/// Quartz job that periodically sweeps expired entries from all in-memory rate limit caches +/// and reconciles the tile cache size counter with the database. /// Prevents unbounded memory growth from accumulated expired entries that would otherwise /// only be cleaned when the cache exceeds the 10,000-entry threshold. /// Runs every 5 minutes. Each cache is cleaned independently. @@ -13,13 +15,15 @@ namespace Wayfarer.Jobs; public class RateLimitCleanupJob : IJob { private readonly ILogger _logger; + private readonly IServiceScopeFactory _serviceScopeFactory; - public RateLimitCleanupJob(ILogger logger) + public RateLimitCleanupJob(ILogger logger, IServiceScopeFactory serviceScopeFactory) { _logger = logger; + _serviceScopeFactory = serviceScopeFactory; } - public Task Execute(IJobExecutionContext context) + public async Task Execute(IJobExecutionContext context) { var cancellationToken = context.CancellationToken; var jobDataMap = context.JobDetail.JobDataMap; @@ -54,6 +58,17 @@ public Task Execute(IJobExecutionContext context) "RateLimitCleanupJob completed. Removed {RemovedCount} expired entries.", totalRemoved); } + // Reconcile tile cache size counter with database to correct accumulated drift + // from non-atomic size tracking during concurrent eviction/caching operations. + try + { + await TileCacheService.ReconcileCacheSizeAsync(_serviceScopeFactory); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Tile cache size reconciliation failed (non-critical)"); + } + jobDataMap["Status"] = "Completed"; jobDataMap["StatusMessage"] = $"Removed {totalRemoved} expired entries"; } @@ -67,7 +82,5 @@ public Task Execute(IJobExecutionContext context) jobDataMap["Status"] = "Failed"; _logger.LogError(ex, "Error executing RateLimitCleanupJob"); } - - return Task.CompletedTask; } } diff --git a/Migrations/20260322205010_AddTileCacheZoomXYIndex.Designer.cs b/Migrations/20260322205010_AddTileCacheZoomXYIndex.Designer.cs new file mode 100644 index 00000000..4e91ca87 --- /dev/null +++ b/Migrations/20260322205010_AddTileCacheZoomXYIndex.Designer.cs @@ -0,0 +1,1606 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Wayfarer.Models; + +#nullable disable + +namespace Wayfarer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260322205010_AddTileCacheZoomXYIndex")] + partial class AddTileCacheZoomXYIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext"); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ApplicationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImageCacheExpiryDays") + .HasColumnType("integer"); + + b.Property("IsRegistrationOpen") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LocationAccuracyThresholdMeters") + .HasColumnType("integer"); + + b.Property("LocationDistanceThresholdMeters") + .HasColumnType("integer"); + + b.Property("LocationTimeThresholdMinutes") + .HasColumnType("integer"); + + b.Property("MaxCacheImageSizeInMB") + .HasColumnType("integer"); + + b.Property("MaxCacheTileSizeInMB") + .HasColumnType("integer"); + + b.Property("MaxProxyImageDownloadMB") + .HasColumnType("integer"); + + b.Property("ProxyImageRateLimitEnabled") + .HasColumnType("boolean"); + + b.Property("ProxyImageRateLimitPerMinute") + .HasColumnType("integer"); + + b.Property("TileProviderApiKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TileProviderAttribution") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("TileProviderKey") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TileProviderUrlTemplate") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TileRateLimitAuthenticatedPerMinute") + .HasColumnType("integer"); + + b.Property("TileRateLimitEnabled") + .HasColumnType("boolean"); + + b.Property("TileRateLimitPerMinute") + .HasColumnType("integer"); + + b.Property("UploadSizeLimitMB") + .HasColumnType("integer"); + + b.Property("VisitNotificationCooldownHours") + .HasColumnType("integer"); + + b.Property("VisitedAccuracyMultiplier") + .HasColumnType("double precision"); + + b.Property("VisitedAccuracyRejectMeters") + .HasColumnType("integer"); + + b.Property("VisitedMaxRadiusMeters") + .HasColumnType("integer"); + + b.Property("VisitedMaxSearchRadiusMeters") + .HasColumnType("integer"); + + b.Property("VisitedMinRadiusMeters") + .HasColumnType("integer"); + + b.Property("VisitedPlaceNotesSnapshotMaxHtmlChars") + .HasColumnType("integer"); + + b.Property("VisitedRequiredHits") + .HasColumnType("integer"); + + b.Property("VisitedSuggestionMaxRadiusMultiplier") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ApplicationSettings"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TripTags", b => + { + b.Property("TripId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.HasKey("TripId", "TagId"); + + b.HasIndex("TagId"); + + b.HasIndex("TripId"); + + b.ToTable("TripTags", (string)null); + }); + + modelBuilder.Entity("Wayfarer.Models.ActivityType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("ActivityTypes"); + }); + + modelBuilder.Entity("Wayfarer.Models.ApiToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Token") + .HasColumnType("text"); + + b.Property("TokenHash") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Name", "UserId") + .IsUnique() + .HasDatabaseName("IX_ApiToken_Name_UserId"); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("Wayfarer.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsProtected") + .HasColumnType("boolean"); + + b.Property("IsTimelinePublic") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PublicTimelineTimeThreshold") + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Wayfarer.Models.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("FillHex") + .HasColumnType("text"); + + b.Property("Geometry") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RegionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RegionId"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("Wayfarer.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Wayfarer.Models.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GroupType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrgPeerVisibilityEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InviteeEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("InviteeUserId") + .HasColumnType("text"); + + b.Property("InviterUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("RespondedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("InviteeUserId"); + + b.HasIndex("InviterUserId"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("GroupId", "InviteeUserId") + .IsUnique() + .HasDatabaseName("IX_GroupInvitation_GroupId_InviteeUserId_Pending") + .HasFilter("\"Status\" = 'Pending' AND \"InviteeUserId\" IS NOT NULL"); + + b.ToTable("GroupInvitations"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LeftAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrgPeerVisibilityAccessDisabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("GroupId", "Status") + .HasDatabaseName("IX_GroupMember_GroupId_Status"); + + b.HasIndex("GroupId", "UserId") + .IsUnique(); + + b.ToTable("GroupMembers"); + }); + + modelBuilder.Entity("Wayfarer.Models.HiddenArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Area") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("HiddenAreas"); + }); + + modelBuilder.Entity("Wayfarer.Models.ImageCacheMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CacheKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastAccessed") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Size") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CacheKey") + .IsUnique() + .HasDatabaseName("IX_ImageCacheMetadata_CacheKey"); + + b.ToTable("ImageCacheMetadata"); + }); + + modelBuilder.Entity("Wayfarer.Models.JobHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastRunTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("JobHistories"); + }); + + modelBuilder.Entity("Wayfarer.Models.Location", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Accuracy") + .HasColumnType("double precision"); + + b.Property("ActivityTypeId") + .HasColumnType("integer"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("AddressNumber") + .HasColumnType("text"); + + b.Property("Altitude") + .HasColumnType("double precision"); + + b.Property("AppBuild") + .HasColumnType("text"); + + b.Property("AppVersion") + .HasColumnType("text"); + + b.Property("BatteryLevel") + .HasColumnType("integer"); + + b.Property("Bearing") + .HasColumnType("double precision"); + + b.Property("Coordinates") + .IsRequired() + .HasColumnType("geography(Point, 4326)"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("DeviceModel") + .HasColumnType("text"); + + b.Property("FullAddress") + .HasColumnType("text"); + + b.Property("IdempotencyKey") + .HasColumnType("uuid"); + + b.Property("IsCharging") + .HasColumnType("boolean"); + + b.Property("IsUserInvoked") + .HasColumnType("boolean"); + + b.Property("LocalTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("LocationType") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OsVersion") + .HasColumnType("text"); + + b.Property("Place") + .HasColumnType("text"); + + b.Property("PostCode") + .HasColumnType("text"); + + b.Property("Provider") + .HasColumnType("text"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("Source") + .HasColumnType("text"); + + b.Property("Speed") + .HasColumnType("double precision"); + + b.Property("StreetName") + .HasColumnType("text"); + + b.Property("TimeZoneId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ActivityTypeId"); + + b.HasIndex("Coordinates") + .HasDatabaseName("IX_Location_Coordinates"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Coordinates"), "GIST"); + + b.HasIndex("UserId", "IdempotencyKey") + .IsUnique() + .HasDatabaseName("IX_Location_UserId_IdempotencyKey"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationImport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileType") + .HasColumnType("integer"); + + b.Property("LastImportedRecord") + .HasColumnType("text"); + + b.Property("LastProcessedIndex") + .HasColumnType("integer"); + + b.Property("SkippedDuplicates") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalRecords") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("LocationImports"); + }); + + modelBuilder.Entity("Wayfarer.Models.Place", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IconName") + .HasColumnType("text"); + + b.Property("Location") + .HasColumnType("geography(Point,4326)"); + + b.Property("MarkerColor") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RegionId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RegionId"); + + b.ToTable("Places"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitCandidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsecutiveHits") + .HasColumnType("integer"); + + b.Property("FirstHitUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastHitUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PlaceId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("LastHitUtc") + .HasDatabaseName("IX_PlaceVisitCandidate_LastHitUtc"); + + b.HasIndex("PlaceId"); + + b.HasIndex("UserId", "PlaceId") + .IsUnique() + .HasDatabaseName("IX_PlaceVisitCandidate_UserId_PlaceId"); + + b.ToTable("PlaceVisitCandidates"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArrivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EndedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IconNameSnapshot") + .HasColumnType("text"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MarkerColorSnapshot") + .HasColumnType("text"); + + b.Property("NotesHtml") + .HasColumnType("text"); + + b.Property("PlaceId") + .HasColumnType("uuid"); + + b.Property("PlaceLocationSnapshot") + .HasColumnType("geography(Point,4326)"); + + b.Property("PlaceNameSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionNameSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("Source") + .HasColumnType("text"); + + b.Property("TripIdSnapshot") + .HasColumnType("uuid"); + + b.Property("TripNameSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ArrivedAtUtc") + .HasDatabaseName("IX_PlaceVisitEvent_ArrivedAtUtc"); + + b.HasIndex("PlaceId") + .HasDatabaseName("IX_PlaceVisitEvent_PlaceId"); + + b.HasIndex("UserId", "EndedAtUtc") + .HasDatabaseName("IX_PlaceVisitEvent_UserId_EndedAtUtc"); + + b.ToTable("PlaceVisitEvents"); + }); + + modelBuilder.Entity("Wayfarer.Models.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Center") + .HasColumnType("geography(Point,4326)"); + + b.Property("CoverImageUrl") + .HasColumnType("text"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("TripId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TripId"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Wayfarer.Models.Segment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("EstimatedDistanceKm") + .HasColumnType("double precision"); + + b.Property("EstimatedDuration") + .HasColumnType("interval"); + + b.Property("FromPlaceId") + .HasColumnType("uuid"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RouteGeometry") + .HasColumnType("geography(LineString,4326)"); + + b.Property("ToPlaceId") + .HasColumnType("uuid"); + + b.Property("TripId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("FromPlaceId"); + + b.HasIndex("ToPlaceId"); + + b.HasIndex("TripId"); + + b.ToTable("Segments"); + }); + + modelBuilder.Entity("Wayfarer.Models.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("citext"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Wayfarer.Models.TileCacheMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ETag") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAccessed") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LastModifiedUpstream") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("TileFilePath") + .HasColumnType("text"); + + b.Property("TileLocation") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("X") + .HasColumnType("integer"); + + b.Property("Y") + .HasColumnType("integer"); + + b.Property("Zoom") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TileLocation"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("TileLocation"), "GIST"); + + b.HasIndex("Zoom", "X", "Y") + .IsUnique(); + + b.ToTable("TileCacheMetadata"); + }); + + modelBuilder.Entity("Wayfarer.Models.Trip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CenterLat") + .HasColumnType("double precision"); + + b.Property("CenterLon") + .HasColumnType("double precision"); + + b.Property("CoverImageUrl") + .HasColumnType("text"); + + b.Property("IsPublic") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("ShareProgressEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Zoom") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Trips"); + }); + + 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("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", 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("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TripTags", b => + { + b.HasOne("Wayfarer.Models.Tag", null) + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.Trip", null) + .WithMany() + .HasForeignKey("TripId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.ApiToken", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("ApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Area", b => + { + b.HasOne("Wayfarer.Models.Region", "Region") + .WithMany("Areas") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Wayfarer.Models.Group", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "Owner") + .WithMany("GroupsOwned") + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupInvitation", b => + { + b.HasOne("Wayfarer.Models.Group", "Group") + .WithMany("Invitations") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", "Invitee") + .WithMany("GroupInvitationsReceived") + .HasForeignKey("InviteeUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Wayfarer.Models.ApplicationUser", "Inviter") + .WithMany("GroupInvitationsSent") + .HasForeignKey("InviterUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Invitee"); + + b.Navigation("Inviter"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupMember", b => + { + b.HasOne("Wayfarer.Models.Group", "Group") + .WithMany("Members") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("GroupMemberships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.HiddenArea", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("HiddenAreas") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Location", b => + { + b.HasOne("Wayfarer.Models.ActivityType", "ActivityType") + .WithMany() + .HasForeignKey("ActivityTypeId"); + + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany("Locations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ActivityType"); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationImport", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("LocationImports") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Place", b => + { + b.HasOne("Wayfarer.Models.Region", "Region") + .WithMany("Places") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitCandidate", b => + { + b.HasOne("Wayfarer.Models.Place", "Place") + .WithMany() + .HasForeignKey("PlaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Place"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitEvent", b => + { + b.HasOne("Wayfarer.Models.Place", "Place") + .WithMany() + .HasForeignKey("PlaceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Place"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Region", b => + { + b.HasOne("Wayfarer.Models.Trip", "Trip") + .WithMany("Regions") + .HasForeignKey("TripId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trip"); + }); + + modelBuilder.Entity("Wayfarer.Models.Segment", b => + { + b.HasOne("Wayfarer.Models.Place", "FromPlace") + .WithMany() + .HasForeignKey("FromPlaceId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Wayfarer.Models.Place", "ToPlace") + .WithMany() + .HasForeignKey("ToPlaceId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Wayfarer.Models.Trip", "Trip") + .WithMany("Segments") + .HasForeignKey("TripId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FromPlace"); + + b.Navigation("ToPlace"); + + b.Navigation("Trip"); + }); + + modelBuilder.Entity("Wayfarer.Models.Trip", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("Trips") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.ApplicationUser", b => + { + b.Navigation("ApiTokens"); + + b.Navigation("GroupInvitationsReceived"); + + b.Navigation("GroupInvitationsSent"); + + b.Navigation("GroupMemberships"); + + b.Navigation("GroupsOwned"); + + b.Navigation("HiddenAreas"); + + b.Navigation("LocationImports"); + + b.Navigation("Locations"); + + b.Navigation("Trips"); + }); + + modelBuilder.Entity("Wayfarer.Models.Group", b => + { + b.Navigation("Invitations"); + + b.Navigation("Members"); + }); + + modelBuilder.Entity("Wayfarer.Models.Region", b => + { + b.Navigation("Areas"); + + b.Navigation("Places"); + }); + + modelBuilder.Entity("Wayfarer.Models.Trip", b => + { + b.Navigation("Regions"); + + b.Navigation("Segments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260322205010_AddTileCacheZoomXYIndex.cs b/Migrations/20260322205010_AddTileCacheZoomXYIndex.cs new file mode 100644 index 00000000..26649b0d --- /dev/null +++ b/Migrations/20260322205010_AddTileCacheZoomXYIndex.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Wayfarer.Migrations +{ + /// + public partial class AddTileCacheZoomXYIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_TileCacheMetadata_Zoom_X_Y", + table: "TileCacheMetadata", + columns: new[] { "Zoom", "X", "Y" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_TileCacheMetadata_Zoom_X_Y", + table: "TileCacheMetadata"); + } + } +} diff --git a/Migrations/ApplicationDbContextModelSnapshot.cs b/Migrations/ApplicationDbContextModelSnapshot.cs index 710ee1bf..20b88e9d 100644 --- a/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1226,6 +1226,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("TileLocation"), "GIST"); + b.HasIndex("Zoom", "X", "Y") + .IsUnique(); + b.ToTable("TileCacheMetadata"); }); diff --git a/Models/ApplicationDbContext.cs b/Models/ApplicationDbContext.cs index 2562f18b..277d1f49 100644 --- a/Models/ApplicationDbContext.cs +++ b/Models/ApplicationDbContext.cs @@ -142,6 +142,14 @@ protected override void OnModelCreating(ModelBuilder builder) .HasIndex(t => t.TileLocation) .HasMethod("GIST"); // This creates a spatial index (if you're using PostGIS) + // Composite unique index on (Zoom, X, Y) for fast tile lookups. + // Every tile request queries by these three columns; without this index, + // all FirstOrDefaultAsync(t => t.Zoom == z && t.X == x && t.Y == y) + // calls result in sequential scans. + builder.Entity() + .HasIndex(t => new { t.Zoom, t.X, t.Y }) + .IsUnique(); + // Image Cache Metadata // EF to use the RowVersion in order to handle race conditions in code builder.Entity(b => diff --git a/Services/RateLimitHelper.cs b/Services/RateLimitHelper.cs index e4106d49..2ecbe51a 100644 --- a/Services/RateLimitHelper.cs +++ b/Services/RateLimitHelper.cs @@ -9,8 +9,9 @@ namespace Wayfarer.Services; /// Prevents boundary-batching attacks where a burst at the end of one window plus the start of the /// next could double the effective limit. Thread-safe: uses atomic operations to minimize race conditions. /// Note: during window rotation, the weighted count reads _windowStartTicks, _expirationTicks, -/// and _prevCount non-atomically, so during rotation the weight can be skewed by up to -/// prevCount × ~0.5 in the worst case. This is transient (lasting only the rotation instant) +/// and _prevCount non-atomically, so during rotation the weight can be skewed by up to the full +/// prevCount (not just ~0.5) in the worst case — a concurrent reader may see prevWeight = 0 if +/// _windowStartTicks has not yet been updated. This is transient (lasting only the rotation instant) /// and acceptable for rate limiting. /// Used by and /// . @@ -22,14 +23,22 @@ public static class RateLimitHelper /// internal static readonly long WindowTicks = TimeSpan.FromMinutes(1).Ticks; + /// + /// Hard cap on the number of entries in a rate limit cache. When the cache exceeds this + /// limit after cleanup, the oldest entries (by expiration) are evicted to bring the count + /// down to 80% of the hard cap. Prevents unbounded memory growth from sustained low-rate + /// attacks from many unique IPs that keep entries alive across window rotations. + /// + private const int HardCap = 50_000; + /// /// Tracks the request count and window expiration for rate limiting using a sliding-window /// counter approximation. Maintains the previous window's count so that requests near a /// boundary are weighted, preventing the boundary-batching exploit where an attacker sends /// the full limit at :59s and again at :00s to achieve 2× the intended rate. /// Uses atomic operations (Interlocked) for thread safety. The weighted count reads multiple - /// fields non-atomically, so it may jitter by up to prevCount × ~0.5 during window rotation — - /// transient and acceptable for rate limiting. + /// fields non-atomically, so it may jitter by up to the full prevCount during the rotation + /// instant — transient and acceptable for rate limiting. /// public sealed class RateLimitEntry { @@ -50,6 +59,12 @@ public RateLimitEntry(long expirationTicks) _windowStartTicks = expirationTicks - WindowTicks; } + /// + /// The current window expiration ticks. Used for hard-cap eviction ordering + /// (oldest entries are evicted first when the cache exceeds ). + /// + public long ExpirationTicks => Interlocked.Read(ref _expirationTicks); + /// /// Atomically increments the counter and returns the weighted sliding-window count. /// If the window has expired, rotates: atomically captures and zeroes the current count, @@ -141,6 +156,14 @@ public static bool IsRateLimitExceeded( try { CleanupExpiredEntries(cache, currentTicks); + + // Hard cap: if the cache still exceeds the limit after removing expired entries + // (e.g., sustained low-rate attack from many unique IPs), evict the oldest + // entries by expiration to bring the count down to 80% of the hard cap. + if (cache.Count > HardCap) + { + EvictOldestEntries(cache); + } } finally { @@ -172,9 +195,35 @@ public static void CleanupExpiredEntries(ConcurrentDictionary + /// Evicts the oldest entries (by window expiration) from the cache to bring the count + /// down to 80% of . Prevents unbounded memory growth from sustained + /// low-rate attacks from many unique IPs that keep entries alive across window rotations. + /// + /// The concurrent dictionary to evict from. + private static void EvictOldestEntries(ConcurrentDictionary cache) + { + var targetCount = (int)(HardCap * 0.8); + var toEvictCount = cache.Count - targetCount; + if (toEvictCount <= 0) return; + + var keysToEvict = cache + .OrderBy(kvp => kvp.Value.ExpirationTicks) + .Take(toEvictCount) + .Select(kvp => kvp.Key) + .ToList(); + + foreach (var key in keysToEvict) + { + cache.TryRemove(key, out _); + } + } + /// /// Gets the client IP address from an HTTP context, respecting X-Forwarded-For header /// only when the direct connection is from a trusted proxy (localhost or private IP). + /// Normalizes IPv4-mapped IPv6 addresses to their IPv4 form to prevent aliasing + /// (e.g., "::ffff:192.168.1.1" and "192.168.1.1" map to the same bucket key). /// This prevents spoofing attacks. /// /// The HTTP context to extract the IP from. @@ -192,11 +241,13 @@ public static string GetClientIpAddress(HttpContext context) { var clientIp = forwardedFor.Split(',', StringSplitOptions.RemoveEmptyEntries) .FirstOrDefault()?.Trim(); - // Validate as a well-formed IP to prevent arbitrary strings from being used - // as rate-limit bucket keys (e.g., a malformed header creating unique buckets). - if (!string.IsNullOrEmpty(clientIp) && IPAddress.TryParse(clientIp, out _)) + // Validate as a well-formed IP and normalize to canonical form to prevent + // IPv4/IPv6 aliasing from creating separate rate-limit buckets for the same client. + if (!string.IsNullOrEmpty(clientIp) && IPAddress.TryParse(clientIp, out var parsed)) { - return clientIp; + return parsed.IsIPv4MappedToIPv6 + ? parsed.MapToIPv4().ToString() + : parsed.ToString(); } } } diff --git a/Services/TileCacheService.cs b/Services/TileCacheService.cs index 8de3ba76..58d960c0 100644 --- a/Services/TileCacheService.cs +++ b/Services/TileCacheService.cs @@ -136,8 +136,10 @@ internal static class OutboundBudget /// /// Maximum time to wait for a token before giving up. Callers that time out /// serve stale cache or return 503 (graceful degradation). + /// Reduced from 10s to 3s to prevent thread pool starvation under sustained + /// cold-cache load (multiple users loading maps with many uncached tiles). /// - internal static readonly TimeSpan AcquireTimeout = TimeSpan.FromSeconds(10); + internal static readonly TimeSpan AcquireTimeout = TimeSpan.FromSeconds(3); /// /// Semaphore representing available outbound tokens. Initialized to . @@ -280,6 +282,20 @@ internal static void ResetForTesting() /// public static void StopOutboundBudget() => OutboundBudget.Stop(); + /// + /// Reconciles with the authoritative database sum. + /// Called periodically by to correct drift + /// from non-atomic size tracking during concurrent eviction/caching operations. + /// + /// Service scope factory for creating a database context. + internal static async Task ReconcileCacheSizeAsync(IServiceScopeFactory scopeFactory) + { + using var scope = scopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var dbSum = await dbContext.TileCacheMetadata.SumAsync(t => (long)t.Size); + Interlocked.Exchange(ref _currentCacheSize, dbSum); + } + /// /// Resets all static state so each test starts with a clean slate. /// Must be called between tests to prevent cross-test interference from @@ -417,13 +433,14 @@ public string GetCacheDirectory() /// Accepts an optional delegate for customizing request headers (e.g., conditional headers). /// private async Task SendTileRequestCoreAsync(string tileUrl, - Action? configureRequest = null, bool skipBudget = false) + Action? configureRequest = null, bool skipBudget = false, + CancellationToken cancellationToken = default) { // Acquire an outbound request token. If the budget is exhausted, return null // so callers can gracefully degrade (serve stale cache or return 503). // skipBudget is true on retries — the budget was already acquired on the first attempt, // so retries should not consume additional tokens. - if (!skipBudget && !await OutboundBudget.AcquireAsync().ConfigureAwait(false)) + if (!skipBudget && !await OutboundBudget.AcquireAsync(cancellationToken).ConfigureAwait(false)) { _logger.LogWarning( "Outbound request budget exhausted — throttling upstream request for {TileUrl}", @@ -451,7 +468,7 @@ public string GetCacheDirectory() // Let the caller add conditional headers (If-None-Match, If-Modified-Since, etc.) configureRequest?.Invoke(request); - var response = await _httpClient.SendAsync(request); + var response = await _httpClient.SendAsync(request, cancellationToken); if (IsRedirectStatus(response.StatusCode)) { @@ -497,9 +514,10 @@ public string GetCacheDirectory() /// /// The upstream tile URL. /// If true, skips outbound budget acquisition (used on retries). - private Task SendTileRequestAsync(string tileUrl, bool skipBudget = false) + private Task SendTileRequestAsync(string tileUrl, bool skipBudget = false, + CancellationToken cancellationToken = default) { - return SendTileRequestCoreAsync(tileUrl, skipBudget: skipBudget); + return SendTileRequestCoreAsync(tileUrl, skipBudget: skipBudget, cancellationToken: cancellationToken); } /// @@ -507,7 +525,7 @@ public string GetCacheDirectory() /// Returns the response (caller checks for 304 vs 200). /// private Task SendConditionalTileRequestAsync(string tileUrl, string? etag, - DateTime? lastModified) + DateTime? lastModified, CancellationToken cancellationToken = default) { return SendTileRequestCoreAsync(tileUrl, request => { @@ -524,7 +542,7 @@ public string GetCacheDirectory() { request.Headers.IfModifiedSince = new DateTimeOffset(lastModified.Value, TimeSpan.Zero); } - }); + }, cancellationToken: cancellationToken); } private static bool IsRedirectStatus(HttpStatusCode statusCode) @@ -655,7 +673,8 @@ private void WriteSidecarMetadata(string tileFilePath, TileSidecarMetadata metad /// For zoom levels >= 9, metadata is stored (or updated) in the database. /// For zoom levels 0-8, metadata is stored as a JSON sidecar file. /// - public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoordinate, string yCoordinate) + public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoordinate, string yCoordinate, + CancellationToken cancellationToken = default) { try { @@ -679,7 +698,7 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord // On retries, skip budget acquisition — the token was already consumed // on the first attempt. This prevents HTTP 5xx retries from exhausting // the entire burst budget under upstream failures. - using var response = await SendTileRequestAsync(tileUrl, skipBudget: budgetAcquired); + using var response = await SendTileRequestAsync(tileUrl, skipBudget: budgetAcquired, cancellationToken: cancellationToken); if (response == null) { // Budget exhaustion means the system is at capacity — retrying is futile @@ -865,7 +884,7 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord /// If the file is missing, downloads and caches the tile. /// public async Task RetrieveTileAsync(string zoomLevel, string xCoordinate, string yCoordinate, - string? tileUrl = null) + string? tileUrl = null, CancellationToken cancellationToken = default) { try { @@ -987,7 +1006,7 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord var flight = _revalidationFlights.GetOrAdd(tileKey, _ => new Lazy>( () => RevalidateTileAsync(tileUrl, tileFilePath, tileKey, zoomLvl, - xVal, yVal, etag, lastModified))); + xVal, yVal, etag, lastModified, cancellationToken))); try { var result = await flight.Value; @@ -1030,7 +1049,7 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord } _logger.LogDebug("Tile not in cache. Fetching from: {TileUrl}", TileProviderCatalog.RedactApiKey(tileUrl)); - await CacheTileAsync(tileUrl, zoomLevel, xCoordinate, yCoordinate); + await CacheTileAsync(tileUrl, zoomLevel, xCoordinate, yCoordinate, cancellationToken); // After fetching, read the file. No lock needed for reads (see fast-path comment above). byte[]? fetchedTileData = null; @@ -1066,9 +1085,10 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord /// scoped DbContext may be disposed while other callers are still awaiting the result. /// private async Task RevalidateTileAsync(string tileUrl, string tileFilePath, string tileKey, - int zoom, int x, int y, string? etag, DateTime? lastModified) + int zoom, int x, int y, string? etag, DateTime? lastModified, + CancellationToken cancellationToken = default) { - using var response = await SendConditionalTileRequestAsync(tileUrl, etag, lastModified); + using var response = await SendConditionalTileRequestAsync(tileUrl, etag, lastModified, cancellationToken); if (response == null) { _logger.LogWarning("Conditional tile request rejected for {TileUrl}", @@ -1305,8 +1325,7 @@ private async Task EvictDbTilesAsync() var dbContext = scope.ServiceProvider.GetRequiredService(); // Retrieve a batch of the least recently accessed tile IDs and sizes. - // AsNoTracking avoids loading RowVersion into the change tracker, so a concurrent - // LastAccessed update won't cause DbUpdateConcurrencyException on our delete. + // AsNoTracking + projection avoids loading full entities or RowVersions. var tilesToEvict = await dbContext.TileCacheMetadata .OrderBy(t => t.LastAccessed) .Take(LRU_TO_EVICT) @@ -1318,7 +1337,6 @@ private async Task EvictDbTilesAsync() // If the delete fails, no files are deleted — cache stays consistent. // If it succeeds but file deletion later fails, orphaned files are harmless // and self-correcting (next cache write for that tile overwrites them). - long totalEvictedSize = tilesToEvict.Sum(t => (long)t.Size); var filePaths = tilesToEvict .Select(t => Path.Combine(_cacheDirectory, $"{t.Zoom}_{t.X}_{t.Y}.png")) .ToList(); @@ -1326,11 +1344,13 @@ private async Task EvictDbTilesAsync() try { - // Re-fetch by ID and delete. The fresh fetch picks up the current RowVersion, - // so there is no stale-token conflict even if LastAccessed changed concurrently. + // Re-fetch by ID to get tracked entities with current RowVersion. + // Compute totalEvictedSize from the re-fetched entities (not the initial projection) + // to minimize drift when tile sizes change between the two queries. var toDelete = await dbContext.TileCacheMetadata .Where(t => tileIds.Contains(t.Id)) .ToListAsync(); + long totalEvictedSize = toDelete.Sum(t => (long)t.Size); dbContext.TileCacheMetadata.RemoveRange(toDelete); await dbContext.SaveChangesAsync(); // Decrement after successful commit to keep _currentCacheSize consistent with DB reality. @@ -1344,28 +1364,29 @@ private async Task EvictDbTilesAsync() } // Phase 2: Delete files (best-effort, after DB commit succeeded). - foreach (var tileFilePath in filePaths) + // Single lock acquisition for the entire batch to avoid convoy effects + // where per-file locking serializes all concurrent writes during eviction. + await _cacheLock.WaitAsync(); + try { - try + foreach (var tileFilePath in filePaths) { - await _cacheLock.WaitAsync(); try { if (File.Exists(tileFilePath)) { File.Delete(tileFilePath); - _logger.LogInformation("Tile file deleted: {TileFilePath}", tileFilePath); } } - finally + catch (Exception ex) { - _cacheLock.Release(); + _logger.LogError(ex, "Failed to delete tile file: {TileFilePath}", tileFilePath); } } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to delete tile file: {TileFilePath}", tileFilePath); - } + } + finally + { + _cacheLock.Release(); } _logger.LogInformation("Evicted tiles to maintain cache size."); @@ -1444,9 +1465,14 @@ public async Task PurgeAllCacheAsync() const int maxRetries = 3; // Max number of retries const int delayBetweenRetries = 1000; // Delay between retries in milliseconds - // Phase 1: Collect files and their DB metadata into batches. - // Phase 2: Commit DB deletions first (consistent with EvictDbTilesAsync ordering). - // Phase 3: Delete files from disk after DB commit succeeds. + // Bulk-load all DB metadata into a dictionary keyed by file path. + // This replaces O(N) individual DB queries (one per file) with a single query, + // preventing connection pool exhaustion on large caches (100K+ tiles). + var allMetadata = await dbContext.TileCacheMetadata + .ToDictionaryAsync(t => t.TileFilePath ?? string.Empty, t => t); + + // Collect files and their DB metadata into batches. + // DB deletions are committed first (consistent with EvictDbTilesAsync ordering). // If DB commit fails, no files are deleted — cache stays consistent. var batch = new List<(TileCacheMetadata? Meta, string FilePath, long FileSize)>(); @@ -1454,10 +1480,7 @@ public async Task PurgeAllCacheAsync() { try { - // Use the full file path for querying DB records. - var fileToPurge = await dbContext.TileCacheMetadata - .Where(t => t.TileFilePath == file) - .FirstOrDefaultAsync(); + allMetadata.TryGetValue(file, out var fileToPurge); long fileSize = File.Exists(file) ? new FileInfo(file).Length : 0; batch.Add((fileToPurge, file, fileSize)); @@ -1578,14 +1601,15 @@ await RetryOperationAsync(async () => } // Phase 2: Delete files from disk (best-effort, after DB commit succeeded). + // Single lock acquisition for the entire batch to avoid convoy effects. // Only decrement _currentCacheSize for DB-tracked tiles (zoom >= 9, Meta != null). // Zoom 0-8 tiles are not tracked in _currentCacheSize, so decrementing them // would drive the counter negative and permanently disable eviction. - foreach (var (meta, filePath, fileSize) in batch) + await _cacheLock.WaitAsync(); + try { - try + foreach (var (meta, filePath, fileSize) in batch) { - await _cacheLock.WaitAsync(); try { if (File.Exists(filePath)) @@ -1597,15 +1621,15 @@ await RetryOperationAsync(async () => } } } - finally + catch (Exception ex) { - _cacheLock.Release(); + _logger.LogError(ex, "Failed to delete purged file: {File}", filePath); } } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to delete purged file: {File}", filePath); - } + } + finally + { + _cacheLock.Release(); } } @@ -1639,8 +1663,8 @@ private async Task RetryOperationAsync(Func operation, int maxRetries, int /// Purges all LRU tile cache (zoom levels >= 9) from both file system and database. /// Uses DB-first ordering consistent with : /// commit DB deletions first, then delete files. If DB fails, no files are deleted. - /// No explicit transaction needed — - /// uses an implicit transaction. + /// Deletes are chunked (1000 IDs per batch) to avoid PostgreSQL query plan explosion + /// from large IN clauses. /// public async Task PurgeLRUCacheAsync() { @@ -1661,37 +1685,56 @@ public async Task PurgeLRUCacheAsync() .Select(t => (FilePath: t.TileFilePath, Size: (long)t.Size)) .ToList(); - // Phase 1: Commit DB deletions first. - // Re-fetches by ID inside the retry lambda so each attempt starts with a clean - // change tracker — prevents entity tracking conflicts on retry. + // Phase 1: Commit DB deletions first in chunks of 1000 IDs. + // Chunking prevents PostgreSQL query plan explosion from large IN clauses. + // Re-fetches entities by ID inside the retry lambda so each attempt starts + // with a clean change tracker — prevents entity tracking conflicts on retry. var lruIds = lruCache.Select(t => t.Id).ToList(); - await RetryOperationAsync(async () => + const int chunkSize = 1000; + foreach (var chunk in lruIds.Chunk(chunkSize)) { - dbContext.ChangeTracker.Clear(); - var toDelete = await dbContext.TileCacheMetadata - .Where(t => lruIds.Contains(t.Id)) - .ToListAsync(); - if (toDelete.Any()) + var chunkList = chunk.ToList(); + await RetryOperationAsync(async () => { - dbContext.TileCacheMetadata.RemoveRange(toDelete); - await dbContext.SaveChangesAsync(); - } - }, 3, 1000); + dbContext.ChangeTracker.Clear(); + var toDelete = await dbContext.TileCacheMetadata + .Where(t => chunkList.Contains(t.Id)) + .ToListAsync(); + if (toDelete.Any()) + { + dbContext.TileCacheMetadata.RemoveRange(toDelete); + await dbContext.SaveChangesAsync(); + } + }, 3, 1000); + } _logger.LogInformation("LRU purge: {Count} DB records deleted.", lruCache.Count); // Phase 2: Delete files from disk (best-effort, after DB commit succeeded). - foreach (var (filePath, fileSize) in fileInfo) + // Single lock acquisition for the entire batch to avoid convoy effects. + await _cacheLock.WaitAsync(); + try { - try + foreach (var (filePath, fileSize) in fileInfo) { - await DeleteCacheFileAsync(filePath, fileSize); - } - catch (Exception e) - { - _logger.LogError(e, "Error deleting LRU cache file {File}", filePath); + try + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + Interlocked.Add(ref _currentCacheSize, -fileSize); + } + } + catch (Exception e) + { + _logger.LogError(e, "Error deleting LRU cache file {File}", filePath); + } } } + finally + { + _cacheLock.Release(); + } } /// From 69de2c828dee00dd7600e57dad3308a06b64fa96 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 22 Mar 2026 23:23:43 +0200 Subject: [PATCH 15/18] Fix review pass 8: per-IP outbound budget, IPv6 normalization, purge drift, revalidation cancellation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HIGH: Add per-IP outbound budget tracking (default 30 cache misses/min/IP) to prevent a single client from monopolizing global outbound tokens via uncached tile requests. Configurable in Admin Settings, 0 = disabled. - HIGH: Normalize IPv4-mapped IPv6 on the direct-IP return path in GetClientIpAddress — previously only normalized on the X-Forwarded-For path, allowing rate limit bypass via dual-stack aliasing. - MEDIUM: PurgeBatchAsync and PurgeLRUCacheAsync now capture actual sizes from re-fetched entities (not stale projected sizes) for _currentCacheSize decrement, consistent with EvictDbTilesAsync pattern. - MEDIUM-LOW: Revalidation coalescing now uses CancellationToken.None for the outbound HTTP request so a disconnecting first caller doesn't cancel the request for all coalesced waiters. --- Areas/Admin/Controllers/SettingsController.cs | 2 + Areas/Admin/Views/Settings/Index.cshtml | 13 + Areas/Public/Controllers/TilesController.cs | 9 + CHANGELOG.md | 12 + Jobs/RateLimitCleanupJob.cs | 5 + ...TileOutboundBudgetPerIpSetting.Designer.cs | 1609 +++++++++++++++++ ...11925_AddTileOutboundBudgetPerIpSetting.cs | 29 + .../ApplicationDbContextModelSnapshot.cs | 3 + Models/ApplicationSettings.cs | 12 + Services/RateLimitHelper.cs | 5 + Services/TileCacheService.cs | 64 +- .../Controllers/TilesControllerTests.cs | 1 + .../Services/RateLimitHelperTests.cs | 31 + 13 files changed, 1787 insertions(+), 8 deletions(-) create mode 100644 Migrations/20260322211925_AddTileOutboundBudgetPerIpSetting.Designer.cs create mode 100644 Migrations/20260322211925_AddTileOutboundBudgetPerIpSetting.cs diff --git a/Areas/Admin/Controllers/SettingsController.cs b/Areas/Admin/Controllers/SettingsController.cs index aa984744..927f6805 100644 --- a/Areas/Admin/Controllers/SettingsController.cs +++ b/Areas/Admin/Controllers/SettingsController.cs @@ -184,6 +184,7 @@ void Track(string name, T oldVal, T newVal) Track("TileRateLimitEnabled", currentSettings.TileRateLimitEnabled, updatedSettings.TileRateLimitEnabled); Track("TileRateLimitPerMinute", currentSettings.TileRateLimitPerMinute, updatedSettings.TileRateLimitPerMinute); Track("TileRateLimitAuthenticatedPerMinute", currentSettings.TileRateLimitAuthenticatedPerMinute, updatedSettings.TileRateLimitAuthenticatedPerMinute); + Track("TileOutboundBudgetPerIpPerMinute", currentSettings.TileOutboundBudgetPerIpPerMinute, updatedSettings.TileOutboundBudgetPerIpPerMinute); // Trip Place Auto-Visited settings Track("VisitedRequiredHits", currentSettings.VisitedRequiredHits, updatedSettings.VisitedRequiredHits); @@ -228,6 +229,7 @@ void Track(string name, T oldVal, T newVal) currentSettings.TileRateLimitEnabled = updatedSettings.TileRateLimitEnabled; currentSettings.TileRateLimitPerMinute = updatedSettings.TileRateLimitPerMinute; currentSettings.TileRateLimitAuthenticatedPerMinute = updatedSettings.TileRateLimitAuthenticatedPerMinute; + currentSettings.TileOutboundBudgetPerIpPerMinute = updatedSettings.TileOutboundBudgetPerIpPerMinute; // Trip Place Auto-Visited settings currentSettings.VisitedRequiredHits = updatedSettings.VisitedRequiredHits; diff --git a/Areas/Admin/Views/Settings/Index.cshtml b/Areas/Admin/Views/Settings/Index.cshtml index f588a282..49db0986 100644 --- a/Areas/Admin/Views/Settings/Index.cshtml +++ b/Areas/Admin/Views/Settings/Index.cshtml @@ -626,6 +626,19 @@ +
+ +
+ + miss/min +
+ + Default: @ApplicationSettings.DefaultTileOutboundBudgetPerIpPerMinute. Max upstream fetches per IP per minute. 0 = disabled. + + +
+ +
Who is affected: diff --git a/Areas/Public/Controllers/TilesController.cs b/Areas/Public/Controllers/TilesController.cs index 1b71f6c8..a8c93901 100644 --- a/Areas/Public/Controllers/TilesController.cs +++ b/Areas/Public/Controllers/TilesController.cs @@ -42,6 +42,15 @@ public class TilesController : Controller ///
internal static readonly ConcurrentDictionary AuthRateLimitCache = new(); + /// + /// Thread-safe dictionary for tracking per-IP outbound budget consumption (cache miss rate). + /// Prevents a single IP from monopolizing the global outbound token budget by limiting how + /// many upstream tile fetches a single client can trigger per minute. + /// Uses the same sliding-window pattern as request rate limiting. + /// Exposed internally for periodic background cleanup by . + /// + internal static readonly ConcurrentDictionary OutboundBudgetCache = new(); + private readonly ILogger _logger; private readonly TileCacheService _tileCacheService; private readonly IApplicationSettingsService _settingsService; diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e6b97c2..3a193491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # CHANGELOG +## [1.2.20] - 2026-03-22 + +### Added +- Per-IP outbound budget tracking (default 30 cache misses/min/IP) — prevents a single client from monopolizing the global outbound token budget (#204) +- Admin UI field for configuring per-IP outbound budget limit (0 = disabled) (#204) + +### Fixed +- **HIGH:** Outbound budget starvation DoS — a single attacker could exhaust all outbound tokens with uncached tile requests, denying service to legitimate users (#204) +- **HIGH:** IPv4-mapped IPv6 addresses not normalized on direct-IP path — `::ffff:x.x.x.x` and `x.x.x.x` created separate rate-limit buckets, bypassing limits (#204) +- **MEDIUM:** `PurgeBatchAsync` and `PurgeLRUCacheAsync` decremented `_currentCacheSize` using stale projected sizes instead of re-fetched entity sizes, causing cache size drift (#204) +- **MEDIUM-LOW:** Revalidation coalescing captured first caller's `CancellationToken` — client disconnect cancelled outbound request for all coalesced waiters (#204) + ## [1.2.19] - 2026-03-22 ### Added diff --git a/Jobs/RateLimitCleanupJob.cs b/Jobs/RateLimitCleanupJob.cs index 09764ca4..2cca6310 100644 --- a/Jobs/RateLimitCleanupJob.cs +++ b/Jobs/RateLimitCleanupJob.cs @@ -47,6 +47,11 @@ public async Task Execute(IJobExecutionContext context) RateLimitHelper.CleanupExpiredEntries(TilesController.AuthRateLimitCache, currentTicks); totalRemoved += before - TilesController.AuthRateLimitCache.Count; + // Tile per-IP outbound budget cache (keyed by IP). + before = TilesController.OutboundBudgetCache.Count; + RateLimitHelper.CleanupExpiredEntries(TilesController.OutboundBudgetCache, currentTicks); + totalRemoved += before - TilesController.OutboundBudgetCache.Count; + // Image proxy rate limit cache (keyed by IP). before = TripViewerController.RateLimitCache.Count; RateLimitHelper.CleanupExpiredEntries(TripViewerController.RateLimitCache, currentTicks); diff --git a/Migrations/20260322211925_AddTileOutboundBudgetPerIpSetting.Designer.cs b/Migrations/20260322211925_AddTileOutboundBudgetPerIpSetting.Designer.cs new file mode 100644 index 00000000..6a47c7bc --- /dev/null +++ b/Migrations/20260322211925_AddTileOutboundBudgetPerIpSetting.Designer.cs @@ -0,0 +1,1609 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Wayfarer.Models; + +#nullable disable + +namespace Wayfarer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260322211925_AddTileOutboundBudgetPerIpSetting")] + partial class AddTileOutboundBudgetPerIpSetting + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext"); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ApplicationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImageCacheExpiryDays") + .HasColumnType("integer"); + + b.Property("IsRegistrationOpen") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LocationAccuracyThresholdMeters") + .HasColumnType("integer"); + + b.Property("LocationDistanceThresholdMeters") + .HasColumnType("integer"); + + b.Property("LocationTimeThresholdMinutes") + .HasColumnType("integer"); + + b.Property("MaxCacheImageSizeInMB") + .HasColumnType("integer"); + + b.Property("MaxCacheTileSizeInMB") + .HasColumnType("integer"); + + b.Property("MaxProxyImageDownloadMB") + .HasColumnType("integer"); + + b.Property("ProxyImageRateLimitEnabled") + .HasColumnType("boolean"); + + b.Property("ProxyImageRateLimitPerMinute") + .HasColumnType("integer"); + + b.Property("TileOutboundBudgetPerIpPerMinute") + .HasColumnType("integer"); + + b.Property("TileProviderApiKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TileProviderAttribution") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("TileProviderKey") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TileProviderUrlTemplate") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TileRateLimitAuthenticatedPerMinute") + .HasColumnType("integer"); + + b.Property("TileRateLimitEnabled") + .HasColumnType("boolean"); + + b.Property("TileRateLimitPerMinute") + .HasColumnType("integer"); + + b.Property("UploadSizeLimitMB") + .HasColumnType("integer"); + + b.Property("VisitNotificationCooldownHours") + .HasColumnType("integer"); + + b.Property("VisitedAccuracyMultiplier") + .HasColumnType("double precision"); + + b.Property("VisitedAccuracyRejectMeters") + .HasColumnType("integer"); + + b.Property("VisitedMaxRadiusMeters") + .HasColumnType("integer"); + + b.Property("VisitedMaxSearchRadiusMeters") + .HasColumnType("integer"); + + b.Property("VisitedMinRadiusMeters") + .HasColumnType("integer"); + + b.Property("VisitedPlaceNotesSnapshotMaxHtmlChars") + .HasColumnType("integer"); + + b.Property("VisitedRequiredHits") + .HasColumnType("integer"); + + b.Property("VisitedSuggestionMaxRadiusMultiplier") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ApplicationSettings"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TripTags", b => + { + b.Property("TripId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.HasKey("TripId", "TagId"); + + b.HasIndex("TagId"); + + b.HasIndex("TripId"); + + b.ToTable("TripTags", (string)null); + }); + + modelBuilder.Entity("Wayfarer.Models.ActivityType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("ActivityTypes"); + }); + + modelBuilder.Entity("Wayfarer.Models.ApiToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Token") + .HasColumnType("text"); + + b.Property("TokenHash") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Name", "UserId") + .IsUnique() + .HasDatabaseName("IX_ApiToken_Name_UserId"); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("Wayfarer.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsProtected") + .HasColumnType("boolean"); + + b.Property("IsTimelinePublic") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PublicTimelineTimeThreshold") + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Wayfarer.Models.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("FillHex") + .HasColumnType("text"); + + b.Property("Geometry") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RegionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RegionId"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("Wayfarer.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Wayfarer.Models.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GroupType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrgPeerVisibilityEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InviteeEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("InviteeUserId") + .HasColumnType("text"); + + b.Property("InviterUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("RespondedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("InviteeUserId"); + + b.HasIndex("InviterUserId"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("GroupId", "InviteeUserId") + .IsUnique() + .HasDatabaseName("IX_GroupInvitation_GroupId_InviteeUserId_Pending") + .HasFilter("\"Status\" = 'Pending' AND \"InviteeUserId\" IS NOT NULL"); + + b.ToTable("GroupInvitations"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LeftAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrgPeerVisibilityAccessDisabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("GroupId", "Status") + .HasDatabaseName("IX_GroupMember_GroupId_Status"); + + b.HasIndex("GroupId", "UserId") + .IsUnique(); + + b.ToTable("GroupMembers"); + }); + + modelBuilder.Entity("Wayfarer.Models.HiddenArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Area") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("HiddenAreas"); + }); + + modelBuilder.Entity("Wayfarer.Models.ImageCacheMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CacheKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastAccessed") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Size") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CacheKey") + .IsUnique() + .HasDatabaseName("IX_ImageCacheMetadata_CacheKey"); + + b.ToTable("ImageCacheMetadata"); + }); + + modelBuilder.Entity("Wayfarer.Models.JobHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastRunTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("JobHistories"); + }); + + modelBuilder.Entity("Wayfarer.Models.Location", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Accuracy") + .HasColumnType("double precision"); + + b.Property("ActivityTypeId") + .HasColumnType("integer"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("AddressNumber") + .HasColumnType("text"); + + b.Property("Altitude") + .HasColumnType("double precision"); + + b.Property("AppBuild") + .HasColumnType("text"); + + b.Property("AppVersion") + .HasColumnType("text"); + + b.Property("BatteryLevel") + .HasColumnType("integer"); + + b.Property("Bearing") + .HasColumnType("double precision"); + + b.Property("Coordinates") + .IsRequired() + .HasColumnType("geography(Point, 4326)"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("DeviceModel") + .HasColumnType("text"); + + b.Property("FullAddress") + .HasColumnType("text"); + + b.Property("IdempotencyKey") + .HasColumnType("uuid"); + + b.Property("IsCharging") + .HasColumnType("boolean"); + + b.Property("IsUserInvoked") + .HasColumnType("boolean"); + + b.Property("LocalTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("LocationType") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OsVersion") + .HasColumnType("text"); + + b.Property("Place") + .HasColumnType("text"); + + b.Property("PostCode") + .HasColumnType("text"); + + b.Property("Provider") + .HasColumnType("text"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("Source") + .HasColumnType("text"); + + b.Property("Speed") + .HasColumnType("double precision"); + + b.Property("StreetName") + .HasColumnType("text"); + + b.Property("TimeZoneId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ActivityTypeId"); + + b.HasIndex("Coordinates") + .HasDatabaseName("IX_Location_Coordinates"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Coordinates"), "GIST"); + + b.HasIndex("UserId", "IdempotencyKey") + .IsUnique() + .HasDatabaseName("IX_Location_UserId_IdempotencyKey"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationImport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileType") + .HasColumnType("integer"); + + b.Property("LastImportedRecord") + .HasColumnType("text"); + + b.Property("LastProcessedIndex") + .HasColumnType("integer"); + + b.Property("SkippedDuplicates") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalRecords") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("LocationImports"); + }); + + modelBuilder.Entity("Wayfarer.Models.Place", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IconName") + .HasColumnType("text"); + + b.Property("Location") + .HasColumnType("geography(Point,4326)"); + + b.Property("MarkerColor") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RegionId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RegionId"); + + b.ToTable("Places"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitCandidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsecutiveHits") + .HasColumnType("integer"); + + b.Property("FirstHitUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastHitUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PlaceId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("LastHitUtc") + .HasDatabaseName("IX_PlaceVisitCandidate_LastHitUtc"); + + b.HasIndex("PlaceId"); + + b.HasIndex("UserId", "PlaceId") + .IsUnique() + .HasDatabaseName("IX_PlaceVisitCandidate_UserId_PlaceId"); + + b.ToTable("PlaceVisitCandidates"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArrivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EndedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IconNameSnapshot") + .HasColumnType("text"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MarkerColorSnapshot") + .HasColumnType("text"); + + b.Property("NotesHtml") + .HasColumnType("text"); + + b.Property("PlaceId") + .HasColumnType("uuid"); + + b.Property("PlaceLocationSnapshot") + .HasColumnType("geography(Point,4326)"); + + b.Property("PlaceNameSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionNameSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("Source") + .HasColumnType("text"); + + b.Property("TripIdSnapshot") + .HasColumnType("uuid"); + + b.Property("TripNameSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ArrivedAtUtc") + .HasDatabaseName("IX_PlaceVisitEvent_ArrivedAtUtc"); + + b.HasIndex("PlaceId") + .HasDatabaseName("IX_PlaceVisitEvent_PlaceId"); + + b.HasIndex("UserId", "EndedAtUtc") + .HasDatabaseName("IX_PlaceVisitEvent_UserId_EndedAtUtc"); + + b.ToTable("PlaceVisitEvents"); + }); + + modelBuilder.Entity("Wayfarer.Models.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Center") + .HasColumnType("geography(Point,4326)"); + + b.Property("CoverImageUrl") + .HasColumnType("text"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("TripId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TripId"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Wayfarer.Models.Segment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("EstimatedDistanceKm") + .HasColumnType("double precision"); + + b.Property("EstimatedDuration") + .HasColumnType("interval"); + + b.Property("FromPlaceId") + .HasColumnType("uuid"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RouteGeometry") + .HasColumnType("geography(LineString,4326)"); + + b.Property("ToPlaceId") + .HasColumnType("uuid"); + + b.Property("TripId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("FromPlaceId"); + + b.HasIndex("ToPlaceId"); + + b.HasIndex("TripId"); + + b.ToTable("Segments"); + }); + + modelBuilder.Entity("Wayfarer.Models.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("citext"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Wayfarer.Models.TileCacheMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ETag") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAccessed") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LastModifiedUpstream") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("TileFilePath") + .HasColumnType("text"); + + b.Property("TileLocation") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("X") + .HasColumnType("integer"); + + b.Property("Y") + .HasColumnType("integer"); + + b.Property("Zoom") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TileLocation"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("TileLocation"), "GIST"); + + b.HasIndex("Zoom", "X", "Y") + .IsUnique(); + + b.ToTable("TileCacheMetadata"); + }); + + modelBuilder.Entity("Wayfarer.Models.Trip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CenterLat") + .HasColumnType("double precision"); + + b.Property("CenterLon") + .HasColumnType("double precision"); + + b.Property("CoverImageUrl") + .HasColumnType("text"); + + b.Property("IsPublic") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("ShareProgressEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Zoom") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Trips"); + }); + + 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("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", 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("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TripTags", b => + { + b.HasOne("Wayfarer.Models.Tag", null) + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.Trip", null) + .WithMany() + .HasForeignKey("TripId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.ApiToken", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("ApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Area", b => + { + b.HasOne("Wayfarer.Models.Region", "Region") + .WithMany("Areas") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Wayfarer.Models.Group", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "Owner") + .WithMany("GroupsOwned") + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupInvitation", b => + { + b.HasOne("Wayfarer.Models.Group", "Group") + .WithMany("Invitations") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", "Invitee") + .WithMany("GroupInvitationsReceived") + .HasForeignKey("InviteeUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Wayfarer.Models.ApplicationUser", "Inviter") + .WithMany("GroupInvitationsSent") + .HasForeignKey("InviterUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Invitee"); + + b.Navigation("Inviter"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupMember", b => + { + b.HasOne("Wayfarer.Models.Group", "Group") + .WithMany("Members") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("GroupMemberships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.HiddenArea", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("HiddenAreas") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Location", b => + { + b.HasOne("Wayfarer.Models.ActivityType", "ActivityType") + .WithMany() + .HasForeignKey("ActivityTypeId"); + + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany("Locations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ActivityType"); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationImport", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("LocationImports") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Place", b => + { + b.HasOne("Wayfarer.Models.Region", "Region") + .WithMany("Places") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitCandidate", b => + { + b.HasOne("Wayfarer.Models.Place", "Place") + .WithMany() + .HasForeignKey("PlaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Place"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitEvent", b => + { + b.HasOne("Wayfarer.Models.Place", "Place") + .WithMany() + .HasForeignKey("PlaceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Place"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Region", b => + { + b.HasOne("Wayfarer.Models.Trip", "Trip") + .WithMany("Regions") + .HasForeignKey("TripId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trip"); + }); + + modelBuilder.Entity("Wayfarer.Models.Segment", b => + { + b.HasOne("Wayfarer.Models.Place", "FromPlace") + .WithMany() + .HasForeignKey("FromPlaceId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Wayfarer.Models.Place", "ToPlace") + .WithMany() + .HasForeignKey("ToPlaceId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Wayfarer.Models.Trip", "Trip") + .WithMany("Segments") + .HasForeignKey("TripId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FromPlace"); + + b.Navigation("ToPlace"); + + b.Navigation("Trip"); + }); + + modelBuilder.Entity("Wayfarer.Models.Trip", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("Trips") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.ApplicationUser", b => + { + b.Navigation("ApiTokens"); + + b.Navigation("GroupInvitationsReceived"); + + b.Navigation("GroupInvitationsSent"); + + b.Navigation("GroupMemberships"); + + b.Navigation("GroupsOwned"); + + b.Navigation("HiddenAreas"); + + b.Navigation("LocationImports"); + + b.Navigation("Locations"); + + b.Navigation("Trips"); + }); + + modelBuilder.Entity("Wayfarer.Models.Group", b => + { + b.Navigation("Invitations"); + + b.Navigation("Members"); + }); + + modelBuilder.Entity("Wayfarer.Models.Region", b => + { + b.Navigation("Areas"); + + b.Navigation("Places"); + }); + + modelBuilder.Entity("Wayfarer.Models.Trip", b => + { + b.Navigation("Regions"); + + b.Navigation("Segments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260322211925_AddTileOutboundBudgetPerIpSetting.cs b/Migrations/20260322211925_AddTileOutboundBudgetPerIpSetting.cs new file mode 100644 index 00000000..702446e1 --- /dev/null +++ b/Migrations/20260322211925_AddTileOutboundBudgetPerIpSetting.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Wayfarer.Migrations +{ + /// + public partial class AddTileOutboundBudgetPerIpSetting : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TileOutboundBudgetPerIpPerMinute", + table: "ApplicationSettings", + type: "integer", + nullable: false, + defaultValue: 30); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "TileOutboundBudgetPerIpPerMinute", + table: "ApplicationSettings"); + } + } +} diff --git a/Migrations/ApplicationDbContextModelSnapshot.cs b/Migrations/ApplicationDbContextModelSnapshot.cs index 20b88e9d..46bc5873 100644 --- a/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Migrations/ApplicationDbContextModelSnapshot.cs @@ -65,6 +65,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ProxyImageRateLimitPerMinute") .HasColumnType("integer"); + b.Property("TileOutboundBudgetPerIpPerMinute") + .HasColumnType("integer"); + b.Property("TileProviderApiKey") .HasMaxLength(200) .HasColumnType("character varying(200)"); diff --git a/Models/ApplicationSettings.cs b/Models/ApplicationSettings.cs index 44eaf693..85409696 100644 --- a/Models/ApplicationSettings.cs +++ b/Models/ApplicationSettings.cs @@ -16,6 +16,7 @@ public class ApplicationSettings public const string DefaultTileProviderAttribution = "© OpenStreetMap contributors"; public const int DefaultTileRateLimitPerMinute = 600; public const int DefaultTileRateLimitAuthenticatedPerMinute = 2000; + public const int DefaultTileOutboundBudgetPerIpPerMinute = 30; public const int DefaultProxyImageRateLimitPerMinute = 200; public const int DefaultMaxProxyImageDownloadMB = 50; @@ -104,6 +105,17 @@ public class ApplicationSettings [Range(100, 50000, ErrorMessage = "Authenticated rate limit must be between 100 and 50,000 requests per minute.")] public int TileRateLimitAuthenticatedPerMinute { get; set; } = DefaultTileRateLimitAuthenticatedPerMinute; + /// + /// Maximum outbound tile fetches (cache misses) per minute per IP address. + /// Prevents a single client from monopolizing the global outbound request budget. + /// A typical cold-cache map load requests 20-30 uncached tiles; default of 30 allows + /// one full map load per minute while preventing sustained scraping attacks. + /// Set to 0 to disable per-IP outbound budget tracking. + /// + [Required] + [Range(0, 1000, ErrorMessage = "Per-IP outbound budget must be between 0 (disabled) and 1,000 per minute.")] + public int TileOutboundBudgetPerIpPerMinute { get; set; } = DefaultTileOutboundBudgetPerIpPerMinute; + /// /// Whether to rate limit anonymous proxy image requests to prevent abuse and origin flooding. /// Authenticated users are never rate limited. diff --git a/Services/RateLimitHelper.cs b/Services/RateLimitHelper.cs index 2ecbe51a..49d3ba3b 100644 --- a/Services/RateLimitHelper.cs +++ b/Services/RateLimitHelper.cs @@ -252,6 +252,11 @@ public static string GetClientIpAddress(HttpContext context) } } + // Normalize direct IP the same way as forwarded IPs to prevent IPv4/IPv6 aliasing + // (e.g., "::ffff:192.168.1.1" and "192.168.1.1" map to the same rate-limit bucket). + if (directIp != null && directIp.IsIPv4MappedToIPv6) + return directIp.MapToIPv4().ToString(); + return directIpString; } diff --git a/Services/TileCacheService.cs b/Services/TileCacheService.cs index 58d960c0..9df0ff11 100644 --- a/Services/TileCacheService.cs +++ b/Services/TileCacheService.cs @@ -6,7 +6,9 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using NetTopologySuite.Geometries; +using Wayfarer.Areas.Public.Controllers; using Wayfarer.Models; +using Wayfarer.Services; using Wayfarer.Parsers; using Wayfarer.Util; @@ -436,7 +438,31 @@ public string GetCacheDirectory() Action? configureRequest = null, bool skipBudget = false, CancellationToken cancellationToken = default) { - // Acquire an outbound request token. If the budget is exhausted, return null + // Per-IP outbound budget check: prevent a single client from monopolizing global tokens. + // Checked before the global budget to fail fast without consuming a global token. + // skipBudget is true on retries — the per-IP check was already passed on the first attempt. + if (!skipBudget) + { + var perIpLimit = _applicationSettings.GetSettings().TileOutboundBudgetPerIpPerMinute; + if (perIpLimit > 0) + { + var ctx = _httpContextAccessor.HttpContext; + if (ctx != null) + { + var clientIp = RateLimitHelper.GetClientIpAddress(ctx); + if (RateLimitHelper.IsRateLimitExceeded( + TilesController.OutboundBudgetCache, clientIp, perIpLimit)) + { + _logger.LogWarning( + "Per-IP outbound budget exceeded for {ClientIp} — throttling upstream request for {TileUrl}", + clientIp, TileProviderCatalog.RedactApiKey(tileUrl)); + return null; + } + } + } + } + + // Acquire a global outbound request token. If the budget is exhausted, return null // so callers can gracefully degrade (serve stale cache or return 503). // skipBudget is true on retries — the budget was already acquired on the first attempt, // so retries should not consume additional tokens. @@ -1003,10 +1029,15 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord if (!string.IsNullOrEmpty(tileUrl)) { // Coalesce concurrent re-validations: only ONE HTTP request per expired tile. + // Use CancellationToken.None so the outbound request completes even if the + // first caller disconnects — other coalesced callers still need the result, + // and the cached data benefits future requests. Individual callers respect their + // own cancellation token when they await flight.Value. HttpClient.Timeout still + // protects against unresponsive upstream servers. var flight = _revalidationFlights.GetOrAdd(tileKey, _ => new Lazy>( () => RevalidateTileAsync(tileUrl, tileFilePath, tileKey, zoomLvl, - xVal, yVal, etag, lastModified, cancellationToken))); + xVal, yVal, etag, lastModified, CancellationToken.None))); try { var result = await flight.Value; @@ -1582,7 +1613,10 @@ private async Task PurgeBatchAsync(ApplicationDbContext dbContext, // Re-fetches entities by ID inside the retry lambda so that each attempt starts // with a clean change tracker and freshly tracked entities — prevents // InvalidOperationException from retrying RemoveRange on already-Deleted entities. + // Captures actual sizes from re-fetched entities (not stale projected sizes from the + // initial bulk load) so Phase 2's _currentCacheSize decrement is accurate. var ids = batch.Where(b => b.Meta != null).Select(b => b.Meta!.Id).ToList(); + var actualSizes = new Dictionary(); if (ids.Any()) { await RetryOperationAsync(async () => @@ -1593,6 +1627,9 @@ await RetryOperationAsync(async () => .ToListAsync(); if (toDelete.Any()) { + // Capture sizes before deletion — these reflect the current DB state, + // not the stale sizes from the initial bulk-load projection. + actualSizes = toDelete.ToDictionary(t => t.Id, t => (long)t.Size); dbContext.TileCacheMetadata.RemoveRange(toDelete); var affectedRows = await dbContext.SaveChangesAsync(); _logger.LogInformation("Purge batch DB commit completed. Rows affected: {Rows}", affectedRows); @@ -1605,6 +1642,7 @@ await RetryOperationAsync(async () => // Only decrement _currentCacheSize for DB-tracked tiles (zoom >= 9, Meta != null). // Zoom 0-8 tiles are not tracked in _currentCacheSize, so decrementing them // would drive the counter negative and permanently disable eviction. + // Uses actualSizes from re-fetched entities to minimize drift. await _cacheLock.WaitAsync(); try { @@ -1615,9 +1653,9 @@ await RetryOperationAsync(async () => if (File.Exists(filePath)) { File.Delete(filePath); - if (meta != null) + if (meta != null && actualSizes.TryGetValue(meta.Id, out var actualSize)) { - Interlocked.Add(ref _currentCacheSize, -meta.Size); + Interlocked.Add(ref _currentCacheSize, -actualSize); } } } @@ -1680,16 +1718,19 @@ public async Task PurgeLRUCacheAsync() if (!lruCache.Any()) return; - // Collect file paths and sizes before DB deletion. + // Collect file paths with IDs for Phase 2 size lookup. var fileInfo = lruCache - .Select(t => (FilePath: t.TileFilePath, Size: (long)t.Size)) + .Select(t => (Id: t.Id, FilePath: t.TileFilePath, Size: (long)t.Size)) .ToList(); // Phase 1: Commit DB deletions first in chunks of 1000 IDs. // Chunking prevents PostgreSQL query plan explosion from large IN clauses. // Re-fetches entities by ID inside the retry lambda so each attempt starts // with a clean change tracker — prevents entity tracking conflicts on retry. + // Captures actual sizes from re-fetched entities (not stale projected sizes) + // so Phase 2's _currentCacheSize decrement is accurate. var lruIds = lruCache.Select(t => t.Id).ToList(); + var actualSizes = new Dictionary(); const int chunkSize = 1000; foreach (var chunk in lruIds.Chunk(chunkSize)) { @@ -1702,6 +1743,9 @@ await RetryOperationAsync(async () => .ToListAsync(); if (toDelete.Any()) { + // Capture sizes before deletion — these reflect the current DB state. + foreach (var t in toDelete) + actualSizes[t.Id] = (long)t.Size; dbContext.TileCacheMetadata.RemoveRange(toDelete); await dbContext.SaveChangesAsync(); } @@ -1712,17 +1756,21 @@ await RetryOperationAsync(async () => // Phase 2: Delete files from disk (best-effort, after DB commit succeeded). // Single lock acquisition for the entire batch to avoid convoy effects. + // Uses actualSizes from re-fetched entities to minimize _currentCacheSize drift. await _cacheLock.WaitAsync(); try { - foreach (var (filePath, fileSize) in fileInfo) + foreach (var (id, filePath, _) in fileInfo) { try { if (File.Exists(filePath)) { File.Delete(filePath); - Interlocked.Add(ref _currentCacheSize, -fileSize); + if (actualSizes.TryGetValue(id, out var actualSize)) + { + Interlocked.Add(ref _currentCacheSize, -actualSize); + } } } catch (Exception e) diff --git a/tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs b/tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs index 49aa184d..c9ffb40b 100644 --- a/tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs +++ b/tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs @@ -32,6 +32,7 @@ public TilesControllerTests() { TilesController.RateLimitCache.Clear(); TilesController.AuthRateLimitCache.Clear(); + TilesController.OutboundBudgetCache.Clear(); } [Fact] diff --git a/tests/Wayfarer.Tests/Services/RateLimitHelperTests.cs b/tests/Wayfarer.Tests/Services/RateLimitHelperTests.cs index 3e69b8dd..cf7d1be0 100644 --- a/tests/Wayfarer.Tests/Services/RateLimitHelperTests.cs +++ b/tests/Wayfarer.Tests/Services/RateLimitHelperTests.cs @@ -1,4 +1,6 @@ using System.Collections.Concurrent; +using System.Net; +using Microsoft.AspNetCore.Http; using Wayfarer.Services; using Xunit; @@ -188,4 +190,33 @@ public void SlidingWindow_FullDecay_AllowsFullLimitAfterQuietPeriod() // After full decay, most (or all) of the limit should be available again. Assert.True(allowed >= limit - 2, $"Expected at least {limit - 2} allowed after full decay, got {allowed}"); } + + /// + /// Verifies that GetClientIpAddress normalizes IPv4-mapped IPv6 addresses on the direct-IP path + /// (not just the X-Forwarded-For path), preventing rate-limit bucket aliasing. + /// + [Fact] + public void GetClientIpAddress_NormalizesIPv4MappedIPv6_OnDirectPath() + { + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = IPAddress.Parse("::ffff:192.168.1.1"); + + var result = RateLimitHelper.GetClientIpAddress(context); + + Assert.Equal("192.168.1.1", result); + } + + /// + /// Verifies that a regular IPv4 direct IP is returned unchanged. + /// + [Fact] + public void GetClientIpAddress_ReturnsIPv4Unchanged_OnDirectPath() + { + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = IPAddress.Parse("203.0.113.5"); + + var result = RateLimitHelper.GetClientIpAddress(context); + + Assert.Equal("203.0.113.5", result); + } } From 3e7b561f8b05149ba4f160caee7c48483571c597 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 22 Mar 2026 23:39:16 +0200 Subject: [PATCH 16/18] Fix review pass 9: purge robustness, lock chunking, DI safety, per-IP revalidation budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MEDIUM: PurgeAllCacheAsync replaces ToDictionaryAsync with foreach-based dictionary build to handle anomalous duplicate TileFilePath values gracefully (last-wins) instead of throwing ArgumentException at runtime. - MEDIUM: PurgeBatchAsync and PurgeLRUCacheAsync file deletion now uses chunked lock acquisition (100 files per lock) instead of holding _cacheLock for the entire batch. Allows CacheTileAsync writes to interleave during large purges. - MEDIUM: Add explicit AddHttpContextAccessor() in ConfigureServices() so TileCacheService's dependency is not fragile on implicit transitive registration. - MEDIUM: Per-IP outbound budget now works for coalesced revalidation flights. Client IP is captured eagerly in RetrieveTileAsync (where HttpContext is available) and threaded through RevalidateTileAsync → SendConditionalTileRequestAsync → SendTileRequestCoreAsync. Previously, HttpContext was null for coalesced tasks, silently bypassing the per-IP budget check. --- Program.cs | 4 ++ Services/TileCacheService.cs | 136 +++++++++++++++++++++-------------- 2 files changed, 87 insertions(+), 53 deletions(-) diff --git a/Program.cs b/Program.cs index e2427891..2f361b09 100644 --- a/Program.cs +++ b/Program.cs @@ -457,6 +457,10 @@ static void ConfigureQuartz(WebApplicationBuilder builder) // Method to configure services for the application static void ConfigureServices(WebApplicationBuilder builder) { + // Explicitly register IHttpContextAccessor for services that need it (e.g., TileCacheService). + // Some framework components may register it implicitly, but explicit registration is safer. + builder.Services.AddHttpContextAccessor(); + // Register memory cache for application services builder.Services.AddMemoryCache(); diff --git a/Services/TileCacheService.cs b/Services/TileCacheService.cs index 9df0ff11..8840d393 100644 --- a/Services/TileCacheService.cs +++ b/Services/TileCacheService.cs @@ -436,29 +436,36 @@ public string GetCacheDirectory() /// private async Task SendTileRequestCoreAsync(string tileUrl, Action? configureRequest = null, bool skipBudget = false, - CancellationToken cancellationToken = default) + string? clientIp = null, CancellationToken cancellationToken = default) { // Per-IP outbound budget check: prevent a single client from monopolizing global tokens. // Checked before the global budget to fail fast without consuming a global token. // skipBudget is true on retries — the per-IP check was already passed on the first attempt. + // clientIp may be passed explicitly by callers (e.g., coalesced revalidation) where + // HttpContext is no longer available; falls back to HttpContext if not provided. if (!skipBudget) { var perIpLimit = _applicationSettings.GetSettings().TileOutboundBudgetPerIpPerMinute; if (perIpLimit > 0) { - var ctx = _httpContextAccessor.HttpContext; - if (ctx != null) + var resolvedIp = clientIp; + if (resolvedIp == null) { - var clientIp = RateLimitHelper.GetClientIpAddress(ctx); - if (RateLimitHelper.IsRateLimitExceeded( - TilesController.OutboundBudgetCache, clientIp, perIpLimit)) + var ctx = _httpContextAccessor.HttpContext; + if (ctx != null) { - _logger.LogWarning( - "Per-IP outbound budget exceeded for {ClientIp} — throttling upstream request for {TileUrl}", - clientIp, TileProviderCatalog.RedactApiKey(tileUrl)); - return null; + resolvedIp = RateLimitHelper.GetClientIpAddress(ctx); } } + + if (resolvedIp != null && RateLimitHelper.IsRateLimitExceeded( + TilesController.OutboundBudgetCache, resolvedIp, perIpLimit)) + { + _logger.LogWarning( + "Per-IP outbound budget exceeded for {ClientIp} — throttling upstream request for {TileUrl}", + resolvedIp, TileProviderCatalog.RedactApiKey(tileUrl)); + return null; + } } } @@ -541,9 +548,9 @@ public string GetCacheDirectory() /// The upstream tile URL. /// If true, skips outbound budget acquisition (used on retries). private Task SendTileRequestAsync(string tileUrl, bool skipBudget = false, - CancellationToken cancellationToken = default) + string? clientIp = null, CancellationToken cancellationToken = default) { - return SendTileRequestCoreAsync(tileUrl, skipBudget: skipBudget, cancellationToken: cancellationToken); + return SendTileRequestCoreAsync(tileUrl, skipBudget: skipBudget, clientIp: clientIp, cancellationToken: cancellationToken); } /// @@ -551,7 +558,7 @@ public string GetCacheDirectory() /// Returns the response (caller checks for 304 vs 200). /// private Task SendConditionalTileRequestAsync(string tileUrl, string? etag, - DateTime? lastModified, CancellationToken cancellationToken = default) + DateTime? lastModified, string? clientIp = null, CancellationToken cancellationToken = default) { return SendTileRequestCoreAsync(tileUrl, request => { @@ -568,7 +575,7 @@ public string GetCacheDirectory() { request.Headers.IfModifiedSince = new DateTimeOffset(lastModified.Value, TimeSpan.Zero); } - }, cancellationToken: cancellationToken); + }, clientIp: clientIp, cancellationToken: cancellationToken); } private static bool IsRedirectStatus(HttpStatusCode statusCode) @@ -914,6 +921,13 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord { try { + // Capture client IP eagerly while HttpContext is available. + // Coalesced revalidation flights may execute after the originating request completes, + // at which point HttpContext is null. Passing the IP explicitly ensures the per-IP + // outbound budget check works for revalidation requests. + var httpContext = _httpContextAccessor.HttpContext; + var clientIp = httpContext != null ? RateLimitHelper.GetClientIpAddress(httpContext) : null; + if (!int.TryParse(zoomLevel, out var zoomLvl) || !int.TryParse(xCoordinate, out var xVal) || !int.TryParse(yCoordinate, out var yVal)) @@ -1037,7 +1051,7 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord var flight = _revalidationFlights.GetOrAdd(tileKey, _ => new Lazy>( () => RevalidateTileAsync(tileUrl, tileFilePath, tileKey, zoomLvl, - xVal, yVal, etag, lastModified, CancellationToken.None))); + xVal, yVal, etag, lastModified, clientIp, CancellationToken.None))); try { var result = await flight.Value; @@ -1117,9 +1131,9 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord /// private async Task RevalidateTileAsync(string tileUrl, string tileFilePath, string tileKey, int zoom, int x, int y, string? etag, DateTime? lastModified, - CancellationToken cancellationToken = default) + string? clientIp = null, CancellationToken cancellationToken = default) { - using var response = await SendConditionalTileRequestAsync(tileUrl, etag, lastModified, cancellationToken); + using var response = await SendConditionalTileRequestAsync(tileUrl, etag, lastModified, clientIp, cancellationToken); if (response == null) { _logger.LogWarning("Conditional tile request rejected for {TileUrl}", @@ -1499,8 +1513,14 @@ public async Task PurgeAllCacheAsync() // Bulk-load all DB metadata into a dictionary keyed by file path. // This replaces O(N) individual DB queries (one per file) with a single query, // preventing connection pool exhaustion on large caches (100K+ tiles). - var allMetadata = await dbContext.TileCacheMetadata - .ToDictionaryAsync(t => t.TileFilePath ?? string.Empty, t => t); + // Uses foreach instead of ToDictionary to handle anomalous duplicate TileFilePath + // values gracefully (last-wins) instead of throwing ArgumentException. + var allMetadataList = await dbContext.TileCacheMetadata.ToListAsync(); + var allMetadata = new Dictionary(allMetadataList.Count); + foreach (var t in allMetadataList) + { + allMetadata[t.TileFilePath ?? string.Empty] = t; + } // Collect files and their DB metadata into batches. // DB deletions are committed first (consistent with EvictDbTilesAsync ordering). @@ -1638,36 +1658,41 @@ await RetryOperationAsync(async () => } // Phase 2: Delete files from disk (best-effort, after DB commit succeeded). - // Single lock acquisition for the entire batch to avoid convoy effects. + // Chunked lock acquisition (100 files per lock) to avoid blocking CacheTileAsync writes + // for the entire purge duration when deleting thousands of files. // Only decrement _currentCacheSize for DB-tracked tiles (zoom >= 9, Meta != null). // Zoom 0-8 tiles are not tracked in _currentCacheSize, so decrementing them // would drive the counter negative and permanently disable eviction. // Uses actualSizes from re-fetched entities to minimize drift. - await _cacheLock.WaitAsync(); - try + const int deleteChunkSize = 100; + foreach (var chunk in batch.Chunk(deleteChunkSize)) { - foreach (var (meta, filePath, fileSize) in batch) + await _cacheLock.WaitAsync(); + try { - try + foreach (var (meta, filePath, fileSize) in chunk) { - if (File.Exists(filePath)) + try { - File.Delete(filePath); - if (meta != null && actualSizes.TryGetValue(meta.Id, out var actualSize)) + if (File.Exists(filePath)) { - Interlocked.Add(ref _currentCacheSize, -actualSize); + File.Delete(filePath); + if (meta != null && actualSizes.TryGetValue(meta.Id, out var actualSize)) + { + Interlocked.Add(ref _currentCacheSize, -actualSize); + } } } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to delete purged file: {File}", filePath); + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete purged file: {File}", filePath); + } } } - } - finally - { - _cacheLock.Release(); + finally + { + _cacheLock.Release(); + } } } @@ -1755,33 +1780,38 @@ await RetryOperationAsync(async () => _logger.LogInformation("LRU purge: {Count} DB records deleted.", lruCache.Count); // Phase 2: Delete files from disk (best-effort, after DB commit succeeded). - // Single lock acquisition for the entire batch to avoid convoy effects. + // Chunked lock acquisition (100 files per lock) to avoid blocking CacheTileAsync writes + // for the entire purge duration when deleting thousands of files. // Uses actualSizes from re-fetched entities to minimize _currentCacheSize drift. - await _cacheLock.WaitAsync(); - try + const int deleteChunkSize = 100; + foreach (var chunk in fileInfo.Chunk(deleteChunkSize)) { - foreach (var (id, filePath, _) in fileInfo) + await _cacheLock.WaitAsync(); + try { - try + foreach (var (id, filePath, _) in chunk) { - if (File.Exists(filePath)) + try { - File.Delete(filePath); - if (actualSizes.TryGetValue(id, out var actualSize)) + if (File.Exists(filePath)) { - Interlocked.Add(ref _currentCacheSize, -actualSize); + File.Delete(filePath); + if (actualSizes.TryGetValue(id, out var actualSize)) + { + Interlocked.Add(ref _currentCacheSize, -actualSize); + } } } - } - catch (Exception e) - { - _logger.LogError(e, "Error deleting LRU cache file {File}", filePath); + catch (Exception e) + { + _logger.LogError(e, "Error deleting LRU cache file {File}", filePath); + } } } - } - finally - { - _cacheLock.Release(); + finally + { + _cacheLock.Release(); + } } } From 2ccaa7eb34983af3f84ec607975ae2ea60e9be6a Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 22 Mar 2026 23:55:56 +0200 Subject: [PATCH 17/18] Fix review pass 10: concurrent insert race, purge memory, retry safety - HIGH: CacheTileAsync now catches DbUpdateException on concurrent tile insert. Two requests for the same uncached tile both saw null and called Add; one hit the unique index constraint. Now handled as a benign race at debug log level. - MEDIUM: PurgeAllCacheAsync bulk-load projects only Id + TileFilePath with AsNoTracking instead of loading full entities (geometry, ETag, RowVersion). PurgeBatchAsync uses lightweight (int? MetaId, string, long) tuples. - MEDIUM: RetryOperationAsync catches only DbUpdateException instead of all exceptions. Non-transient errors propagate immediately. Retry log level changed from Error to Warning. --- CHANGELOG.md | 3 +++ Services/TileCacheService.cs | 49 ++++++++++++++++++++++++------------ 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a193491..8f0db9dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,10 @@ ### Fixed - **HIGH:** Outbound budget starvation DoS — a single attacker could exhaust all outbound tokens with uncached tile requests, denying service to legitimate users (#204) - **HIGH:** IPv4-mapped IPv6 addresses not normalized on direct-IP path — `::ffff:x.x.x.x` and `x.x.x.x` created separate rate-limit buckets, bypassing limits (#204) +- **HIGH:** Concurrent insert race in `CacheTileAsync` — two requests for the same uncached tile could trigger an unhandled `DbUpdateException` from the unique index; now caught as a benign race (#204) - **MEDIUM:** `PurgeBatchAsync` and `PurgeLRUCacheAsync` decremented `_currentCacheSize` using stale projected sizes instead of re-fetched entity sizes, causing cache size drift (#204) +- **MEDIUM:** `PurgeAllCacheAsync` loaded full entities into memory for the metadata dictionary; now projects only `Id` and `TileFilePath` with `AsNoTracking` to reduce memory usage on large caches (#204) +- **MEDIUM:** `RetryOperationAsync` caught all exceptions including non-transient ones; now catches only `DbUpdateException` so non-recoverable errors propagate immediately (#204) - **MEDIUM-LOW:** Revalidation coalescing captured first caller's `CancellationToken` — client disconnect cancelled outbound request for all coalesced waiters (#204) ## [1.2.19] - 2026-03-22 diff --git a/Services/TileCacheService.cs b/Services/TileCacheService.cs index 8840d393..8430982d 100644 --- a/Services/TileCacheService.cs +++ b/Services/TileCacheService.cs @@ -838,10 +838,22 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string xCoord // Note: RowVersion is handled automatically by EF Core with [Timestamp] }; - _dbContext.TileCacheMetadata.Add(tileMetadata); - await _dbContext.SaveChangesAsync(); - Interlocked.Add(ref _currentCacheSize, tileData?.Length ?? 0); - _logger.LogInformation("Tile metadata stored in database."); + try + { + _dbContext.TileCacheMetadata.Add(tileMetadata); + await _dbContext.SaveChangesAsync(); + Interlocked.Add(ref _currentCacheSize, tileData?.Length ?? 0); + _logger.LogInformation("Tile metadata stored in database."); + } + catch (DbUpdateException) + { + // Benign race: another concurrent request already inserted this tile. + // The unique index on (Zoom, X, Y) prevents duplicates. The file is already + // written and the other request incremented _currentCacheSize. + _logger.LogDebug( + "Tile metadata insert skipped due to concurrent insert (non-critical) z={Zoom} x={X} y={Y}", + zoom, x, y); + } } else { @@ -1515,26 +1527,29 @@ public async Task PurgeAllCacheAsync() // preventing connection pool exhaustion on large caches (100K+ tiles). // Uses foreach instead of ToDictionary to handle anomalous duplicate TileFilePath // values gracefully (last-wins) instead of throwing ArgumentException. - var allMetadataList = await dbContext.TileCacheMetadata.ToListAsync(); - var allMetadata = new Dictionary(allMetadataList.Count); + var allMetadataList = await dbContext.TileCacheMetadata + .AsNoTracking() + .Select(t => new { t.Id, t.TileFilePath }) + .ToListAsync(); + var allMetadata = new Dictionary(allMetadataList.Count); foreach (var t in allMetadataList) { - allMetadata[t.TileFilePath ?? string.Empty] = t; + allMetadata[t.TileFilePath ?? string.Empty] = t.Id; } // Collect files and their DB metadata into batches. // DB deletions are committed first (consistent with EvictDbTilesAsync ordering). // If DB commit fails, no files are deleted — cache stays consistent. - var batch = new List<(TileCacheMetadata? Meta, string FilePath, long FileSize)>(); + var batch = new List<(int? MetaId, string FilePath, long FileSize)>(); foreach (var file in Directory.EnumerateFiles(_cacheDirectory, "*.png")) { try { - allMetadata.TryGetValue(file, out var fileToPurge); + int? metaId = allMetadata.TryGetValue(file, out var id) ? id : null; long fileSize = File.Exists(file) ? new FileInfo(file).Length : 0; - batch.Add((fileToPurge, file, fileSize)); + batch.Add((metaId, file, fileSize)); // Commit and delete in batches. if (batch.Count >= batchSize) @@ -1624,9 +1639,11 @@ private void CleanupSidecarFiles() /// Processes a purge batch: commits DB deletions first, then deletes files from disk. /// Consistent with ordering — if DB commit fails, /// no files are deleted and cache stays consistent. + /// Uses lightweight (MetaId, FilePath, FileSize) tuples instead of full entities + /// to minimize memory usage during large purge operations. /// private async Task PurgeBatchAsync(ApplicationDbContext dbContext, - List<(TileCacheMetadata? Meta, string FilePath, long FileSize)> batch, + List<(int? MetaId, string FilePath, long FileSize)> batch, int maxRetries, int delayBetweenRetries) { // Phase 1: Commit DB deletions first. @@ -1635,7 +1652,7 @@ private async Task PurgeBatchAsync(ApplicationDbContext dbContext, // InvalidOperationException from retrying RemoveRange on already-Deleted entities. // Captures actual sizes from re-fetched entities (not stale projected sizes from the // initial bulk load) so Phase 2's _currentCacheSize decrement is accurate. - var ids = batch.Where(b => b.Meta != null).Select(b => b.Meta!.Id).ToList(); + var ids = batch.Where(b => b.MetaId != null).Select(b => b.MetaId!.Value).ToList(); var actualSizes = new Dictionary(); if (ids.Any()) { @@ -1670,14 +1687,14 @@ await RetryOperationAsync(async () => await _cacheLock.WaitAsync(); try { - foreach (var (meta, filePath, fileSize) in chunk) + foreach (var (metaId, filePath, fileSize) in chunk) { try { if (File.Exists(filePath)) { File.Delete(filePath); - if (meta != null && actualSizes.TryGetValue(meta.Id, out var actualSize)) + if (metaId != null && actualSizes.TryGetValue(metaId.Value, out var actualSize)) { Interlocked.Add(ref _currentCacheSize, -actualSize); } @@ -1706,10 +1723,10 @@ private async Task RetryOperationAsync(Func operation, int maxRetries, int await operation(); break; // Operation succeeded; exit loop. } - catch (Exception e) + catch (DbUpdateException e) { attempt++; - _logger.LogError(e, "Error during operation, retrying... Attempt {Attempt} of {MaxRetries}", attempt, + _logger.LogWarning(e, "Transient DB error during operation, retrying... Attempt {Attempt} of {MaxRetries}", attempt, maxRetries); if (attempt >= maxRetries) { From 71ee070366218509bc8bb67bcbcd983821874f85 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Mon, 23 Mar 2026 00:08:15 +0200 Subject: [PATCH 18/18] Fix review pass 11: add missing ProxyImageRateLimitEnabled to admin UI ProxyImageRateLimitEnabled and ProxyImageRateLimitPerMinute were defined in ApplicationSettings and consumed by TripViewerController/TripsController but never exposed in the admin settings UI or wired in SettingsController. Admins could not toggle or tune proxy image rate limiting. - Add checkbox + hidden-field fallback for ProxyImageRateLimitEnabled - Add numeric input for ProxyImageRateLimitPerMinute - Add audit tracking and property assignment in SettingsController.Update --- Areas/Admin/Controllers/SettingsController.cs | 4 ++ Areas/Admin/Views/Settings/Index.cshtml | 38 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/Areas/Admin/Controllers/SettingsController.cs b/Areas/Admin/Controllers/SettingsController.cs index 927f6805..c310f0d1 100644 --- a/Areas/Admin/Controllers/SettingsController.cs +++ b/Areas/Admin/Controllers/SettingsController.cs @@ -185,6 +185,8 @@ void Track(string name, T oldVal, T newVal) Track("TileRateLimitPerMinute", currentSettings.TileRateLimitPerMinute, updatedSettings.TileRateLimitPerMinute); Track("TileRateLimitAuthenticatedPerMinute", currentSettings.TileRateLimitAuthenticatedPerMinute, updatedSettings.TileRateLimitAuthenticatedPerMinute); Track("TileOutboundBudgetPerIpPerMinute", currentSettings.TileOutboundBudgetPerIpPerMinute, updatedSettings.TileOutboundBudgetPerIpPerMinute); + Track("ProxyImageRateLimitEnabled", currentSettings.ProxyImageRateLimitEnabled, updatedSettings.ProxyImageRateLimitEnabled); + Track("ProxyImageRateLimitPerMinute", currentSettings.ProxyImageRateLimitPerMinute, updatedSettings.ProxyImageRateLimitPerMinute); // Trip Place Auto-Visited settings Track("VisitedRequiredHits", currentSettings.VisitedRequiredHits, updatedSettings.VisitedRequiredHits); @@ -230,6 +232,8 @@ void Track(string name, T oldVal, T newVal) currentSettings.TileRateLimitPerMinute = updatedSettings.TileRateLimitPerMinute; currentSettings.TileRateLimitAuthenticatedPerMinute = updatedSettings.TileRateLimitAuthenticatedPerMinute; currentSettings.TileOutboundBudgetPerIpPerMinute = updatedSettings.TileOutboundBudgetPerIpPerMinute; + currentSettings.ProxyImageRateLimitEnabled = updatedSettings.ProxyImageRateLimitEnabled; + currentSettings.ProxyImageRateLimitPerMinute = updatedSettings.ProxyImageRateLimitPerMinute; // Trip Place Auto-Visited settings currentSettings.VisitedRequiredHits = updatedSettings.VisitedRequiredHits; diff --git a/Areas/Admin/Views/Settings/Index.cshtml b/Areas/Admin/Views/Settings/Index.cshtml index 49db0986..2f98d92a 100644 --- a/Areas/Admin/Views/Settings/Index.cshtml +++ b/Areas/Admin/Views/Settings/Index.cshtml @@ -827,6 +827,44 @@ +
+

Image Proxy Rate Limiting

+

+ Protect upstream image origins from abuse by rate limiting anonymous proxy image requests. + Authenticated users are never rate limited. +

+ +
+
+ + @* Hidden fallback ensures "false" is posted when checkbox is unchecked *@ + + +
+ + When enabled, anonymous image proxy requests are limited per IP. + +
+ +
+ +
+ + req/min +
+ + Default: @ApplicationSettings.DefaultProxyImageRateLimitPerMinute. Max proxy image requests per minute per IP. + + +
+
+