@@ -44,8 +44,14 @@ created_at TEXT NOT NULL
4444 cmd . ExecuteNonQuery ( ) ;
4545}
4646
47+ // --- Rate limiters (in-memory) ---
48+ // Created before Build() so they can be DI-registered and swept by CleanupService.
49+ var rateLimiter = new RateLimiter ( maxRequests : 10 , windowSeconds : 60 ) ;
50+ var analyticsRateLimiter = new RateLimiter ( maxRequests : 30 , windowSeconds : 60 ) ;
51+
4752// Register the cleanup background service
4853builder . Services . AddSingleton ( new PlanDbConfig ( connectionString ) ) ;
54+ builder . Services . AddSingleton ( new RateLimiters ( rateLimiter , analyticsRateLimiter ) ) ;
4955builder . Services . AddHostedService < CleanupService > ( ) ;
5056
5157// Request size limit (10 MB)
@@ -54,10 +60,6 @@ created_at TEXT NOT NULL
5460var app = builder . Build ( ) ;
5561app . UseCors ( ) ;
5662
57- // --- Rate limiters (in-memory) ---
58- var rateLimiter = new RateLimiter ( maxRequests : 10 , windowSeconds : 60 ) ;
59- var analyticsRateLimiter = new RateLimiter ( maxRequests : 30 , windowSeconds : 60 ) ;
60-
6163const int MaxTtlDays = 365 ;
6264
6365// --- Endpoints ---
@@ -161,9 +163,15 @@ created_at TEXT NOT NULL
161163 return Results . BadRequest ( "Invalid JSON" ) ;
162164 }
163165
164- // Strip referrer to domain only (no full URLs with query params)
165- if ( ! string . IsNullOrEmpty ( referrer ) && Uri . TryCreate ( referrer , UriKind . Absolute , out var refUri ) )
166- referrer = refUri . Host ;
166+ // Strip referrer to domain only (no full URLs with query params).
167+ // If it doesn't parse as an absolute URL, drop it — never persist raw
168+ // client-supplied strings, since the dashboard renders referrers in HTML.
169+ if ( ! string . IsNullOrEmpty ( referrer ) )
170+ {
171+ referrer = Uri . TryCreate ( referrer , UriKind . Absolute , out var refUri )
172+ ? refUri . Host
173+ : null ;
174+ }
167175
168176 // Visitor hash: SHA256(IP + User-Agent + date) — unique per day, no PII stored
169177 var ua = ctx . Request . Headers . UserAgent . FirstOrDefault ( ) ?? "" ;
@@ -352,14 +360,18 @@ static string GenerateDeleteToken()
352360
353361record PlanDbConfig ( string ConnectionString ) ;
354362
363+ record RateLimiters ( RateLimiter Share , RateLimiter Analytics ) ;
364+
355365sealed class CleanupService : BackgroundService
356366{
357367 private readonly PlanDbConfig _config ;
368+ private readonly RateLimiters _rateLimiters ;
358369 private readonly ILogger < CleanupService > _logger ;
359370
360- public CleanupService ( PlanDbConfig config , ILogger < CleanupService > logger )
371+ public CleanupService ( PlanDbConfig config , RateLimiters rateLimiters , ILogger < CleanupService > logger )
361372 {
362373 _config = config ;
374+ _rateLimiters = rateLimiters ;
363375 _logger = logger ;
364376 }
365377
@@ -401,6 +413,14 @@ private void Cleanup()
401413 if ( deleted > 0 )
402414 _logger . LogInformation ( "Cleaned up {Count} old page views" , deleted ) ;
403415 }
416+
417+ // Evict stale rate-limiter keys so the dictionary doesn't grow forever.
418+ var shareEvicted = _rateLimiters . Share . Sweep ( ) ;
419+ var analyticsEvicted = _rateLimiters . Analytics . Sweep ( ) ;
420+ if ( shareEvicted + analyticsEvicted > 0 )
421+ _logger . LogInformation (
422+ "Evicted {Share} share + {Analytics} analytics rate-limit keys" ,
423+ shareEvicted , analyticsEvicted ) ;
404424 }
405425 catch ( Exception ex )
406426 {
@@ -441,4 +461,25 @@ public bool IsAllowed(string key)
441461 return true ;
442462 }
443463 }
464+
465+ /// <summary>
466+ /// Evicts keys whose timestamp lists have gone empty. Call periodically
467+ /// so the dictionary doesn't grow forever across unique IPs.
468+ /// Returns the number of keys evicted.
469+ /// </summary>
470+ public int Sweep ( )
471+ {
472+ var cutoff = DateTime . UtcNow . AddSeconds ( - _windowSeconds ) ;
473+ var evicted = 0 ;
474+ foreach ( var kvp in _requests )
475+ {
476+ lock ( kvp . Value )
477+ {
478+ kvp . Value . RemoveAll ( t => t < cutoff ) ;
479+ if ( kvp . Value . Count == 0 && _requests . TryRemove ( kvp ) )
480+ evicted ++ ;
481+ }
482+ }
483+ return evicted ;
484+ }
444485}
0 commit comments