diff --git a/Areas/Admin/Controllers/SettingsController.cs b/Areas/Admin/Controllers/SettingsController.cs index b0df3979..c310f0d1 100644 --- a/Areas/Admin/Controllers/SettingsController.cs +++ b/Areas/Admin/Controllers/SettingsController.cs @@ -134,6 +134,13 @@ public async Task Update(ApplicationSettings updatedSettings) "Minimum cache size is 256 MB (OSM requires at least 7 days of cached tiles). Use -1 to disable."); } + // Authenticated users should always have at least the same rate limit as anonymous users. + if (updatedSettings.TileRateLimitAuthenticatedPerMinute < updatedSettings.TileRateLimitPerMinute) + { + ModelState.AddModelError(nameof(updatedSettings.TileRateLimitAuthenticatedPerMinute), + "Authenticated rate limit must be equal to or greater than the anonymous rate limit."); + } + if (currentSettings != null) { // Validate tile provider settings before model validation. @@ -176,6 +183,10 @@ 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); + Track("ProxyImageRateLimitEnabled", currentSettings.ProxyImageRateLimitEnabled, updatedSettings.ProxyImageRateLimitEnabled); + Track("ProxyImageRateLimitPerMinute", currentSettings.ProxyImageRateLimitPerMinute, updatedSettings.ProxyImageRateLimitPerMinute); // Trip Place Auto-Visited settings Track("VisitedRequiredHits", currentSettings.VisitedRequiredHits, updatedSettings.VisitedRequiredHits); @@ -219,6 +230,10 @@ void Track(string name, T oldVal, T newVal) currentSettings.TileProviderApiKey = updatedSettings.TileProviderApiKey; currentSettings.TileRateLimitEnabled = updatedSettings.TileRateLimitEnabled; 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 8f628c4c..2f98d92a 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 *@ + @@ -578,28 +580,30 @@

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.

-
+
+ @* Hidden fallback ensures "false" is posted when checkbox is unchecked *@ +
- When enabled, anonymous tile requests are limited per IP address. + When enabled, tile requests are limited for all users.
-
- +
+
req/min @@ -610,14 +614,39 @@
-
+
+ +
+ + req/min +
+ + Default: @ApplicationSettings.DefaultTileRateLimitAuthenticatedPerMinute. Higher limit for trusted users. + + +
+ +
+ +
+ + miss/min +
+ + Default: @ApplicationSettings.DefaultTileOutboundBudgetPerIpPerMinute. Max upstream fetches per IP per minute. 0 = disabled. + + +
+
+
+
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.
@@ -798,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. + + +
+
+
diff --git a/Areas/Public/Controllers/TilesController.cs b/Areas/Public/Controllers/TilesController.cs index 5933716f..a8c93901 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; @@ -29,8 +30,26 @@ public class TilesController : Controller /// /// Thread-safe dictionary for rate limiting anonymous tile requests by IP address. /// Uses atomic operations via to prevent race conditions. + /// Exposed internally for periodic background cleanup by . /// - private static readonly ConcurrentDictionary RateLimitCache = new(); + internal 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. + /// Exposed internally for periodic background cleanup by . + /// + 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; @@ -51,6 +70,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)) { @@ -77,15 +101,41 @@ 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 strategy: anonymous by IP, authenticated by userId (mutually exclusive). + // An authenticated user is NOT also counted against the IP limit. This avoids + // unfairly penalizing users behind shared NATs (e.g., corporate networks). + // The outbound budget (OutboundBudget) provides system-wide protection regardless. + if (settings.TileRateLimitEnabled) { - var clientIp = GetClientIpAddress(); - if (RateLimitHelper.IsRateLimitExceeded(RateLimitCache, clientIp, settings.TileRateLimitPerMinute)) + var userId = User.Identity?.IsAuthenticated == true + ? User.FindFirstValue(ClaimTypes.NameIdentifier) + : null; + + if (userId != null) + { + // 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); + 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."); + // Authenticated user without a NameIdentifier claim — unexpected, log for diagnostics. + // Falls back to the stricter anonymous (IP-based) rate limit as a safe-side default. + if (User.Identity?.IsAuthenticated == true) + { + _logger.LogWarning("Authenticated user without NameIdentifier claim — falling back to IP-based rate limiting"); + } + + // Anonymous user or authenticated user without a NameIdentifier claim — rate limit by IP. + 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); @@ -100,7 +150,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); @@ -110,6 +160,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/Areas/Public/Controllers/TripViewerController.cs b/Areas/Public/Controllers/TripViewerController.cs index 4ac79272..80b42618 100644 --- a/Areas/Public/Controllers/TripViewerController.cs +++ b/Areas/Public/Controllers/TripViewerController.cs @@ -18,8 +18,9 @@ public class TripViewerController : BaseController /// /// Thread-safe dictionary for rate limiting anonymous requests by IP address. /// Uses atomic operations via to prevent race conditions. + /// Exposed internally for periodic background cleanup by . /// - private static readonly ConcurrentDictionary RateLimitCache = new(); + internal static readonly ConcurrentDictionary RateLimitCache = new(); private readonly HttpClient _httpClient; private readonly ITripThumbnailService _thumbnailService; diff --git a/CHANGELOG.md b/CHANGELOG.md index e85fc552..8f0db9dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,78 @@ # 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) +- **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 + +### 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 +- 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, exposed in Admin Settings UI (#204) +- Outbound request budget (token-bucket at 2 req/sec, burst 10) — prevents cache-miss cascading from overwhelming upstream OSM and risking a fair-use block; complies with OSM 2-connection policy via transport-level enforcement (#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) +- Rate limit cleanup flag is now per-cache instance instead of a shared global flag, allowing independent cleanup of anonymous, authenticated, and image proxy caches (#204) +- `X-Forwarded-For` header values are now validated with `IPAddress.TryParse` before use as rate limit keys (#204) +- Outbound budget `StopReplenisher` now cancels the old CTS before creating replacements, eliminating brief replenisher overlap (#204) + +### Added +- `RateLimitCleanupJob` — periodic Quartz job (every 5 minutes) sweeps expired entries from all in-memory rate limit caches, preventing unbounded memory growth (#204) +- Log warning when authenticated user lacks `NameIdentifier` claim and falls back to IP-based rate limiting (#204) + +### Fixed +- Eviction `_currentCacheSize` tracking now decrements after successful DB commit, preventing permanent undercount on failed eviction (#204) +- Tile cache eviction now commits DB deletions before deleting files — previously files were deleted first, leaving orphaned DB records pointing to missing files if `SaveChangesAsync` failed (#204) +- Admin settings checkbox hidden-field fallback for `TileRateLimitEnabled` and `IsRegistrationOpen` — unchecking now correctly posts `false` instead of falling back to C# default (#204) +- **CRITICAL:** Remove global read-lock on tile cache — file reads no longer serialize through `_cacheLock`, eliminating a throughput bottleneck under concurrent map viewers. Writes and deletes retain the exclusive lock; reads catch `IOException` as cache miss (#204) +- **CRITICAL:** Increase outbound budget burst capacity from 2 to 10, reducing cold-cache map load times. OSM's 2-connection policy is now enforced at the transport layer via `SocketsHttpHandler.MaxConnectionsPerServer` (#204) +- **HIGH:** Eviction coalescing — concurrent `CacheTileAsync` calls can no longer trigger simultaneous eviction runs (double-evict). Uses `Interlocked.CompareExchange` guard with `DbUpdateConcurrencyException` handling (#204) +- **HIGH:** `EvictDbTilesAsync` now uses a dedicated `IServiceScope` instead of the per-request `_dbContext`, preventing disposed-context failures when eviction outlives the originating request (#204) +- **HIGH:** `CacheTileAsync` no longer retries on outbound budget exhaustion — breaks immediately instead of blocking up to 30 seconds (3 retries × 10s timeout) (#204) +- **MEDIUM:** Admin settings cross-field validation: authenticated rate limit must be >= anonymous rate limit (#204) + ## [1.2.17] - 2026-03-22 ### Added diff --git a/Jobs/RateLimitCleanupJob.cs b/Jobs/RateLimitCleanupJob.cs new file mode 100644 index 00000000..2cca6310 --- /dev/null +++ b/Jobs/RateLimitCleanupJob.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.DependencyInjection; +using Quartz; +using Wayfarer.Areas.Public.Controllers; +using Wayfarer.Services; + +namespace Wayfarer.Jobs; + +/// +/// 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. +/// +public class RateLimitCleanupJob : IJob +{ + private readonly ILogger _logger; + private readonly IServiceScopeFactory _serviceScopeFactory; + + public RateLimitCleanupJob(ILogger logger, IServiceScopeFactory serviceScopeFactory) + { + _logger = logger; + _serviceScopeFactory = serviceScopeFactory; + } + + public async Task Execute(IJobExecutionContext context) + { + var cancellationToken = context.CancellationToken; + var jobDataMap = context.JobDetail.JobDataMap; + jobDataMap["Status"] = "Scheduled"; + + try + { + cancellationToken.ThrowIfCancellationRequested(); + jobDataMap["Status"] = "In Progress"; + + var currentTicks = DateTime.UtcNow.Ticks; + var totalRemoved = 0; + + // Tile anonymous rate limit cache (keyed by IP). + var before = TilesController.RateLimitCache.Count; + RateLimitHelper.CleanupExpiredEntries(TilesController.RateLimitCache, currentTicks); + totalRemoved += before - TilesController.RateLimitCache.Count; + + // Tile authenticated rate limit cache (keyed by user ID). + before = TilesController.AuthRateLimitCache.Count; + 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); + totalRemoved += before - TripViewerController.RateLimitCache.Count; + + if (totalRemoved > 0) + { + _logger.LogInformation( + "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"; + } + catch (OperationCanceledException) + { + jobDataMap["Status"] = "Cancelled"; + _logger.LogInformation("RateLimitCleanupJob was cancelled."); + } + catch (Exception ex) + { + jobDataMap["Status"] = "Failed"; + _logger.LogError(ex, "Error executing RateLimitCleanupJob"); + } + } +} 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/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/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 2bc2e3cf..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)"); @@ -84,6 +87,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(500) .HasColumnType("character varying(500)"); + b.Property("TileRateLimitAuthenticatedPerMinute") + .HasColumnType("integer"); + b.Property("TileRateLimitEnabled") .HasColumnType("boolean"); @@ -1223,6 +1229,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/Models/ApplicationSettings.cs b/Models/ApplicationSettings.cs index b82995e9..85409696 100644 --- a/Models/ApplicationSettings.cs +++ b/Models/ApplicationSettings.cs @@ -14,7 +14,9 @@ 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 DefaultTileOutboundBudgetPerIpPerMinute = 30; public const int DefaultProxyImageRateLimitPerMinute = 200; public const int DefaultMaxProxyImageDownloadMB = 50; @@ -78,8 +80,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 +95,27 @@ 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; + + /// + /// 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/Program.cs b/Program.cs index 4ddd5079..2f361b09 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() @@ -332,6 +335,7 @@ static void ConfigureQuartz(WebApplicationBuilder builder) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); // ...and any other IJob implementations you'll use // 1) Register your JobFactory & Listeners @@ -423,6 +427,26 @@ static void ConfigureQuartz(WebApplicationBuilder builder) scheduler.ScheduleJob(job, trigger).Wait(); } + // Rate limit cache cleanup job — sweeps expired entries from all in-memory rate limit + // caches every 5 minutes to prevent unbounded memory growth from accumulated stale entries. + var rateLimitJobKey = new JobKey("RateLimitCleanupJob", "Maintenance"); + if (!scheduler.CheckExists(rateLimitJobKey).Result) + { + var job = JobBuilder.Create() + .WithIdentity(rateLimitJobKey) + .StoreDurably() + .Build(); + + var trigger = TriggerBuilder.Create() + .ForJob(job) + .WithIdentity("RateLimitCleanupTrigger", "Maintenance") + .StartNow() + .WithSimpleSchedule(x => x.WithIntervalInMinutes(5).RepeatForever()) + .Build(); + + scheduler.ScheduleJob(job, trigger).Wait(); + } + return scheduler; }); @@ -433,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(); @@ -530,9 +558,13 @@ static void ConfigureServices(WebApplicationBuilder builder) client.DefaultRequestHeaders.AcceptLanguage.Add( new System.Net.Http.Headers.StringWithQualityHeaderValue("en", 0.9)); }) - .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { - AllowAutoRedirect = false + AllowAutoRedirect = false, + // OSM tile usage policy: "maximum of 2 download threads". + // Enforced at the transport layer so the token-bucket OutboundBudget + // can use a higher burst capacity without violating the connection limit. + MaxConnectionsPerServer = 2 }); // Location service, handles location results per zoom and bounds levels diff --git a/Services/RateLimitHelper.cs b/Services/RateLimitHelper.cs index 053f5746..49d3ba3b 100644 --- a/Services/RateLimitHelper.cs +++ b/Services/RateLimitHelper.cs @@ -5,21 +5,47 @@ 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 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 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 /// . /// public static class RateLimitHelper { /// - /// Tracks the request count and window expiration for rate limiting. - /// Uses atomic operations (Interlocked) for thread safety. + /// The duration of one rate-limit window in ticks (1 minute). + /// + 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 the full prevCount during the rotation + /// instant — transient and acceptable for rate limiting. /// 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 +54,27 @@ 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. + /// 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, + /// 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. /// /// 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,42 +82,94 @@ public int IncrementAndGet(long currentTicks, long newExpirationTicks) { if (Interlocked.CompareExchange(ref _expirationTicks, newExpirationTicks, currentExpiration) == currentExpiration) { - 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); } } - 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. /// 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 per cache instance. Only one thread runs cleanup + /// for a given cache at a time; others skip and proceed with rate limiting. + /// Keyed by cache instance reference so separate caches (anonymous, authenticated, image proxy) + /// can be cleaned independently without blocking each other. + /// + private static readonly ConcurrentDictionary _cleanupFlags = new(); + 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 per cache at a time. + // TryAdd returns false if another thread is already cleaning this specific cache. + if (_cleanupFlags.TryAdd(cache, 0)) + { + 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 + { + _cleanupFlags.TryRemove(cache, out _); + } + } } var entry = cache.GetOrAdd(clientIp, _ => new RateLimitEntry(expirationTicks)); @@ -107,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. @@ -127,13 +241,22 @@ public static string GetClientIpAddress(HttpContext context) { var clientIp = forwardedFor.Split(',', StringSplitOptions.RemoveEmptyEntries) .FirstOrDefault()?.Trim(); - if (!string.IsNullOrEmpty(clientIp)) + // 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(); } } } + // 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 75ded3a9..8430982d 100644 --- a/Services/TileCacheService.cs +++ b/Services/TileCacheService.cs @@ -6,13 +6,24 @@ 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; 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; @@ -21,7 +32,9 @@ public class TileCacheService private readonly IHttpContextAccessor _httpContextAccessor; /// - /// Lock for serializing file system operations across all service instances. + /// Lock for serializing file write and delete operations across all service instances. + /// Read operations proceed without locking and catch as a cache miss + /// (file may have been deleted by a concurrent eviction or purge). /// Static because TileCacheService is scoped (per-request) but file operations must be synchronized globally. /// private static readonly SemaphoreSlim _cacheLock = new(1, 1); @@ -55,6 +68,13 @@ public class TileCacheService /// private static long _currentCacheSize = 0; + /// + /// Guards against concurrent eviction runs. Only one eviction can proceed at a time; + /// concurrent callers skip eviction (the in-progress run will free enough space). + /// Uses for lock-free coalescing. + /// + private static int _evictionInProgress = 0; + /// /// Indicates whether _currentCacheSize has been initialized from the database. /// @@ -88,6 +108,196 @@ 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 sustained rate of 2 tokens/sec with a + /// burst capacity of 10 queued requests. OSM's "maximum of 2 download threads" connection + /// policy is enforced at the transport layer via SocketsHttpHandler.MaxConnectionsPerServer + /// in Program.cs, not by this budget. The budget controls throughput (sustained request rate), + /// while MaxConnectionsPerServer controls concurrency (simultaneous TCP connections). + /// Thread-safe: uses for token management and + /// for replenishment. + /// + internal static class OutboundBudget + { + /// + /// 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 = 10; + + /// + /// Replenishment interval — one token is released every this many milliseconds. + /// 500ms = 2 tokens/sec sustained rate, complying with OSM's fair use policy. + /// + 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). + /// 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(3); + + /// + /// 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); + + /// + /// 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 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 volatile Lazy _replenisher = new( + () => StartReplenisher(_replenisherCts.Token), + 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. + /// Stops cleanly when the is cancelled (e.g., during app shutdown). + /// + /// 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)); + try + { + while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false)) + { + // 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 + { + _tokens.Release(); + } + catch (SemaphoreFullException) + { + // Harmless race: another thread released between our check and Release(). + } + } + } + } + catch (OperationCanceledException) + { + // Expected during shutdown or test reset — exit cleanly. + } + }, CancellationToken.None); + } + + /// + /// Cancels the current replenishment task and prepares a fresh Lazy so a new replenisher + /// 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() + { + lock (_stopLock) + { + var oldCts = _replenisherCts; + oldCts.Cancel(); + var newCts = new CancellationTokenSource(); + _replenisherCts = newCts; + _replenisher = new Lazy( + () => StartReplenisher(newCts.Token), + LazyThreadSafetyMode.ExecutionAndPublication); + } + } + + /// + /// 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 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() + { + StopReplenisher(); + // Drain all tokens. + while (_tokens.CurrentCount > 0) + { + _tokens.Wait(0); + } + // Refill to burst capacity. + 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.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 @@ -98,7 +308,9 @@ internal static void ResetStaticStateForTesting() _revalidationFlights.Clear(); _sidecarCache.Clear(); Interlocked.Exchange(ref _currentCacheSize, 0); + Interlocked.Exchange(ref _evictionInProgress, 0); _cacheSizeInitialized = false; + OutboundBudget.ResetForTesting(); } public TileCacheService(ILogger logger, IConfiguration configuration, HttpClient httpClient, @@ -218,11 +430,57 @@ 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) + Action? configureRequest = null, bool skipBudget = false, + 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 resolvedIp = clientIp; + if (resolvedIp == null) + { + var ctx = _httpContextAccessor.HttpContext; + if (ctx != 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; + } + } + } + + // 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. + if (!skipBudget && !await OutboundBudget.AcquireAsync(cancellationToken).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; @@ -243,7 +501,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)) { @@ -287,9 +545,12 @@ 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, + string? clientIp = null, CancellationToken cancellationToken = default) { - return SendTileRequestCoreAsync(tileUrl); + return SendTileRequestCoreAsync(tileUrl, skipBudget: skipBudget, clientIp: clientIp, cancellationToken: cancellationToken); } /// @@ -297,7 +558,7 @@ public string GetCacheDirectory() /// Returns the response (caller checks for 304 vs 200). /// private Task SendConditionalTileRequestAsync(string tileUrl, string? etag, - DateTime? lastModified) + DateTime? lastModified, string? clientIp = null, CancellationToken cancellationToken = default) { return SendTileRequestCoreAsync(tileUrl, request => { @@ -314,7 +575,7 @@ public string GetCacheDirectory() { request.Headers.IfModifiedSince = new DateTimeOffset(lastModified.Value, TimeSpan.Zero); } - }); + }, clientIp: clientIp, cancellationToken: cancellationToken); } private static bool IsRedirectStatus(HttpStatusCode statusCode) @@ -445,7 +706,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 { @@ -462,16 +724,23 @@ 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, cancellationToken: cancellationToken); 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; } + budgetAcquired = true; if (response.IsSuccessStatusCode) { @@ -536,10 +805,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 @@ -558,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 { @@ -592,7 +884,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) @@ -601,8 +895,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; @@ -635,10 +929,17 @@ 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 { + // 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)) @@ -728,22 +1029,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; @@ -753,10 +1055,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))); + xVal, yVal, etag, lastModified, clientIp, CancellationToken.None))); try { var result = await flight.Value; @@ -773,9 +1080,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)) @@ -783,9 +1090,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; @@ -799,11 +1106,10 @@ 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 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)) @@ -811,9 +1117,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; @@ -836,9 +1142,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, + string? clientIp = null, CancellationToken cancellationToken = default) { - using var response = await SendConditionalTileRequestAsync(tileUrl, etag, lastModified); + using var response = await SendConditionalTileRequestAsync(tileUrl, etag, lastModified, clientIp, cancellationToken); if (response == null) { _logger.LogWarning("Conditional tile request rejected for {TileUrl}", @@ -862,13 +1169,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 { @@ -876,16 +1183,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; @@ -1049,52 +1372,80 @@ 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() { - // Retrieve a batch of the least recently accessed tiles. - var tilesToEvict = await _dbContext.TileCacheMetadata + // 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 tile IDs and sizes. + // AsNoTracking + projection avoids loading full entities or RowVersions. + 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(); - foreach (var tile in tilesToEvict) - { - _dbContext.TileCacheMetadata.Remove(tile); - Interlocked.Add(ref _currentCacheSize, -tile.Size); + // Phase 1: Commit DB deletions first. + // 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). + var filePaths = tilesToEvict + .Select(t => Path.Combine(_cacheDirectory, $"{t.Zoom}_{t.X}_{t.Y}.png")) + .ToList(); + var tileIds = tilesToEvict.Select(t => t.Id).ToList(); - // Remove the corresponding file. - var tileFilePath = Path.Combine(_cacheDirectory, $"{tile.Zoom}_{tile.X}_{tile.Y}.png"); - if (!File.Exists(tileFilePath)) - { - _logger.LogWarning("Tile file already deleted: {TileFilePath}", tileFilePath); - continue; - } + try + { + // 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. + Interlocked.Add(ref _currentCacheSize, -totalEvictedSize); + _logger.LogInformation("Evicted {Count} tiles from database.", toDelete.Count); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Eviction DB delete failed — tiles were not evicted"); + return; // Don't decrement _currentCacheSize; rows were not deleted. + } - try + // Phase 2: Delete files (best-effort, after DB commit succeeded). + // 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 + { + foreach (var tileFilePath in filePaths) { - if (File.Exists(tileFilePath)) + try { - await _cacheLock.WaitAsync(); - try + if (File.Exists(tileFilePath)) { - // Serialize file deletes with cache reads/writes. File.Delete(tileFilePath); } - finally - { - _cacheLock.Release(); - } - _logger.LogInformation("Tile file deleted: {TileFilePath}", tileFilePath); + } + catch (Exception ex) + { + _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(); } - await _dbContext.SaveChangesAsync(); _logger.LogInformation("Evicted tiles to maintain cache size."); } @@ -1170,59 +1521,41 @@ 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(); + + // 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). + // Uses foreach instead of ToDictionary to handle anomalous duplicate TileFilePath + // values gracefully (last-wins) instead of throwing ArgumentException. + 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.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<(int? MetaId, string FilePath, long FileSize)>(); foreach (var file in Directory.EnumerateFiles(_cacheDirectory, "*.png")) { try { - // Use the full file path for querying DB records. - var fileToPurge = await dbContext.TileCacheMetadata - .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(); - } + int? metaId = allMetadata.TryGetValue(file, out var id) ? id : null; - 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((metaId, 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) @@ -1231,30 +1564,42 @@ 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) - var orphanRecords = await dbContext.TileCacheMetadata - .Where(t => !File.Exists(t.TileFilePath)) + // Clean up orphan DB records (records without corresponding files on disk). + // 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 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); } @@ -1290,6 +1635,84 @@ 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<(int? MetaId, string FilePath, long FileSize)> batch, + int maxRetries, int delayBetweenRetries) + { + // Phase 1: Commit DB deletions first. + // 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.MetaId != null).Select(b => b.MetaId!.Value).ToList(); + var actualSizes = new Dictionary(); + if (ids.Any()) + { + await RetryOperationAsync(async () => + { + dbContext.ChangeTracker.Clear(); + var toDelete = await dbContext.TileCacheMetadata + .Where(t => ids.Contains(t.Id)) + .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); + } + }, maxRetries, delayBetweenRetries); + } + + // Phase 2: Delete files from disk (best-effort, after DB commit succeeded). + // 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. + const int deleteChunkSize = 100; + foreach (var chunk in batch.Chunk(deleteChunkSize)) + { + await _cacheLock.WaitAsync(); + try + { + foreach (var (metaId, filePath, fileSize) in chunk) + { + try + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + if (metaId != null && actualSizes.TryGetValue(metaId.Value, out var actualSize)) + { + Interlocked.Add(ref _currentCacheSize, -actualSize); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete purged file: {File}", filePath); + } + } + } + finally + { + _cacheLock.Release(); + } + } + } + private async Task RetryOperationAsync(Func operation, int maxRetries, int delayBetweenRetries) { int attempt = 0; @@ -1300,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) { @@ -1318,60 +1741,95 @@ 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. + /// Deletes are chunked (1000 IDs per batch) to avoid PostgreSQL query plan explosion + /// from large IN clauses. /// public async Task PurgeLRUCacheAsync() { using var scope = _serviceScopeFactory.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - using var transaction = await dbContext.Database.BeginTransactionAsync(); - + // Project only the fields needed — AsNoTracking avoids change tracker overhead. var lruCache = await dbContext.TileCacheMetadata + .AsNoTracking() .Where(file => file.Zoom >= 9) - .AsTracking() + .Select(t => new { t.Id, t.TileFilePath, t.Size }) .ToListAsync(); - var recordsToDelete = new List(); + if (!lruCache.Any()) return; + + // Collect file paths with IDs for Phase 2 size lookup. + var fileInfo = lruCache + .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)) + { + var chunkList = chunk.ToList(); + await RetryOperationAsync(async () => + { + dbContext.ChangeTracker.Clear(); + var toDelete = await dbContext.TileCacheMetadata + .Where(t => chunkList.Contains(t.Id)) + .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(); + } + }, 3, 1000); + } + + _logger.LogInformation("LRU purge: {Count} DB records deleted.", lruCache.Count); - foreach (var file in lruCache) + // Phase 2: Delete files from disk (best-effort, after DB commit succeeded). + // 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. + const int deleteChunkSize = 100; + foreach (var chunk in fileInfo.Chunk(deleteChunkSize)) { + await _cacheLock.WaitAsync(); try { - if (File.Exists(file.TileFilePath)) + foreach (var (id, filePath, _) in chunk) { - // Use RetryOperationAsync for file deletion logic - await RetryOperationAsync(() => + try { - 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); + if (File.Exists(filePath)) + { + 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); + } } - // Always mark DB record for deletion, regardless of whether file existed - recordsToDelete.Add(file); } - catch (Exception e) + finally { - _logger.LogError(e, "Error processing file {File}", file.TileFilePath); + _cacheLock.Release(); } } - - 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(); - } } /// diff --git a/tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs b/tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs index 035fb3cc..c9ffb40b 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; @@ -23,6 +24,17 @@ 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(); + TilesController.OutboundBudgetCache.Clear(); + } + [Fact] public async Task GetTile_UnauthorizedWithoutReferer() { @@ -102,6 +114,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 { @@ -179,9 +194,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 { @@ -218,9 +232,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 { @@ -255,10 +268,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); @@ -288,6 +301,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(); @@ -334,7 +434,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(); @@ -345,7 +445,8 @@ private IApplicationSettingsService BuildSettingsService(bool rateLimitEnabled = TileProviderUrlTemplate = ApplicationSettings.DefaultTileProviderUrlTemplate, TileProviderAttribution = ApplicationSettings.DefaultTileProviderAttribution, TileRateLimitEnabled = rateLimitEnabled, - TileRateLimitPerMinute = rateLimitPerMinute + TileRateLimitPerMinute = rateLimitPerMinute, + TileRateLimitAuthenticatedPerMinute = rateLimitAuthenticatedPerMinute }); return appSettings.Object; } diff --git a/tests/Wayfarer.Tests/Services/RateLimitHelperTests.cs b/tests/Wayfarer.Tests/Services/RateLimitHelperTests.cs index 0ee9b392..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; @@ -87,4 +89,134 @@ 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}"); + } + + /// + /// 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); + } } diff --git a/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs b/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs index 8e591b2f..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, @@ -681,6 +711,52 @@ protected override Task SendAsync(HttpRequestMessage reques } } + /// + /// 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 10 — first 10 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; @@ -698,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); }