11using System . Collections . Concurrent ;
22using System . Security . Cryptography ;
3+ using System . Text ;
34using System . Text . Json ;
45using Microsoft . Data . Sqlite ;
56
@@ -30,7 +31,15 @@ CREATE TABLE IF NOT EXISTS plans (
3031 created_at TEXT NOT NULL,
3132 expires_at TEXT NOT NULL,
3233 delete_token TEXT NOT NULL
33- )
34+ );
35+ CREATE TABLE IF NOT EXISTS page_views (
36+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37+ path TEXT NOT NULL,
38+ referrer TEXT,
39+ visitor_hash TEXT NOT NULL,
40+ created_at TEXT NOT NULL
41+ );
42+ CREATE INDEX IF NOT EXISTS idx_pv_created ON page_views(created_at);
3443 """ ;
3544 cmd . ExecuteNonQuery ( ) ;
3645}
@@ -45,8 +54,9 @@ delete_token TEXT NOT NULL
4554var app = builder . Build ( ) ;
4655app . UseCors ( ) ;
4756
48- // --- Rate limiter: 10 shares per minute per IP (in-memory) ---
57+ // --- Rate limiters (in-memory) ---
4958var rateLimiter = new RateLimiter ( maxRequests : 10 , windowSeconds : 60 ) ;
59+ var analyticsRateLimiter = new RateLimiter ( maxRequests : 30 , windowSeconds : 60 ) ;
5060
5161const int MaxTtlDays = 365 ;
5262
@@ -124,6 +134,186 @@ delete_token TEXT NOT NULL
124134 return Results . Content ( result , "application/json" ) ;
125135} ) ;
126136
137+ // --- Analytics: page view tracking ---
138+
139+ app . MapPost ( "/api/event" , async ( HttpContext ctx ) =>
140+ {
141+ // Rate limit: 30 events/min per IP (generous — covers page nav + shares)
142+ var ip = ctx . Connection . RemoteIpAddress ? . ToString ( ) ?? "unknown" ;
143+ if ( ! analyticsRateLimiter . IsAllowed ( ip ) )
144+ return Results . StatusCode ( 429 ) ;
145+
146+ using var reader = new StreamReader ( ctx . Request . Body ) ;
147+ var body = await reader . ReadToEndAsync ( ) ;
148+
149+ string path = "/" ;
150+ string ? referrer = null ;
151+ try
152+ {
153+ using var doc = JsonDocument . Parse ( body ) ;
154+ if ( doc . RootElement . TryGetProperty ( "path" , out var p ) )
155+ path = p . GetString ( ) ?? "/" ;
156+ if ( doc . RootElement . TryGetProperty ( "referrer" , out var r ) )
157+ referrer = r . GetString ( ) ;
158+ }
159+ catch ( JsonException )
160+ {
161+ return Results . BadRequest ( "Invalid JSON" ) ;
162+ }
163+
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 ;
167+
168+ // Visitor hash: SHA256(IP + User-Agent + date) — unique per day, no PII stored
169+ var ua = ctx . Request . Headers . UserAgent . FirstOrDefault ( ) ?? "" ;
170+ var day = DateTime . UtcNow . ToString ( "yyyy-MM-dd" ) ;
171+ var visitorHash = Convert . ToHexString (
172+ SHA256 . HashData ( Encoding . UTF8 . GetBytes ( $ "{ ip } |{ ua } |{ day } ") ) ) . ToLower ( ) [ ..16 ] ;
173+
174+ using var conn = new SqliteConnection ( connectionString ) ;
175+ conn . Open ( ) ;
176+ using var cmd = conn . CreateCommand ( ) ;
177+ cmd . CommandText = "INSERT INTO page_views (path, referrer, visitor_hash, created_at) VALUES (@path, @referrer, @hash, @now)" ;
178+ cmd . Parameters . AddWithValue ( "@path" , path ) ;
179+ cmd . Parameters . AddWithValue ( "@referrer" , ( object ? ) referrer ?? DBNull . Value ) ;
180+ cmd . Parameters . AddWithValue ( "@hash" , visitorHash ) ;
181+ cmd . Parameters . AddWithValue ( "@now" , DateTime . UtcNow . ToString ( "o" ) ) ;
182+ cmd . ExecuteNonQuery ( ) ;
183+
184+ return Results . Ok ( ) ;
185+ } ) ;
186+
187+ app . MapGet ( "/api/stats" , ( ) =>
188+ {
189+ using var conn = new SqliteConnection ( connectionString ) ;
190+ conn . Open ( ) ;
191+ var now = DateTime . UtcNow . ToString ( "o" ) ;
192+ var cutoff30 = DateTime . UtcNow . AddDays ( - 30 ) . ToString ( "o" ) ;
193+ var cutoff7 = DateTime . UtcNow . AddDays ( - 7 ) . ToString ( "o" ) ;
194+ var today = DateTime . UtcNow . ToString ( "yyyy-MM-dd" ) ;
195+
196+ // --- Plan sharing stats ---
197+ long totalShared ;
198+ using ( var cmd = conn . CreateCommand ( ) )
199+ {
200+ cmd . CommandText = "SELECT COUNT(*) FROM plans" ;
201+ totalShared = ( long ) cmd . ExecuteScalar ( ) ! ;
202+ }
203+
204+ long activePlans ;
205+ using ( var cmd = conn . CreateCommand ( ) )
206+ {
207+ cmd . CommandText = "SELECT COUNT(*) FROM plans WHERE expires_at > @now" ;
208+ cmd . Parameters . AddWithValue ( "@now" , now ) ;
209+ activePlans = ( long ) cmd . ExecuteScalar ( ) ! ;
210+ }
211+
212+ var dailyShares = new List < object > ( ) ;
213+ using ( var cmd = conn . CreateCommand ( ) )
214+ {
215+ cmd . CommandText = """
216+ SELECT DATE(created_at) as day, COUNT(*) as count
217+ FROM plans WHERE created_at > @cutoff
218+ GROUP BY DATE(created_at) ORDER BY day
219+ """ ;
220+ cmd . Parameters . AddWithValue ( "@cutoff" , cutoff30 ) ;
221+ using var reader = cmd . ExecuteReader ( ) ;
222+ while ( reader . Read ( ) )
223+ dailyShares . Add ( new { day = reader . GetString ( 0 ) , count = reader . GetInt64 ( 1 ) } ) ;
224+ }
225+
226+ // --- Page view stats ---
227+ long viewsToday ;
228+ using ( var cmd = conn . CreateCommand ( ) )
229+ {
230+ cmd . CommandText = "SELECT COUNT(*) FROM page_views WHERE DATE(created_at) = @today" ;
231+ cmd . Parameters . AddWithValue ( "@today" , today ) ;
232+ viewsToday = ( long ) cmd . ExecuteScalar ( ) ! ;
233+ }
234+
235+ long visitorsToday ;
236+ using ( var cmd = conn . CreateCommand ( ) )
237+ {
238+ cmd . CommandText = "SELECT COUNT(DISTINCT visitor_hash) FROM page_views WHERE DATE(created_at) = @today" ;
239+ cmd . Parameters . AddWithValue ( "@today" , today ) ;
240+ visitorsToday = ( long ) cmd . ExecuteScalar ( ) ! ;
241+ }
242+
243+ long views7d ;
244+ using ( var cmd = conn . CreateCommand ( ) )
245+ {
246+ cmd . CommandText = "SELECT COUNT(*) FROM page_views WHERE created_at > @cutoff" ;
247+ cmd . Parameters . AddWithValue ( "@cutoff" , cutoff7 ) ;
248+ views7d = ( long ) cmd . ExecuteScalar ( ) ! ;
249+ }
250+
251+ long visitors7d ;
252+ using ( var cmd = conn . CreateCommand ( ) )
253+ {
254+ cmd . CommandText = "SELECT COUNT(DISTINCT visitor_hash) FROM page_views WHERE created_at > @cutoff" ;
255+ cmd . Parameters . AddWithValue ( "@cutoff" , cutoff7 ) ;
256+ visitors7d = ( long ) cmd . ExecuteScalar ( ) ! ;
257+ }
258+
259+ long views30d ;
260+ using ( var cmd = conn . CreateCommand ( ) )
261+ {
262+ cmd . CommandText = "SELECT COUNT(*) FROM page_views WHERE created_at > @cutoff" ;
263+ cmd . Parameters . AddWithValue ( "@cutoff" , cutoff30 ) ;
264+ views30d = ( long ) cmd . ExecuteScalar ( ) ! ;
265+ }
266+
267+ long visitors30d ;
268+ using ( var cmd = conn . CreateCommand ( ) )
269+ {
270+ cmd . CommandText = "SELECT COUNT(DISTINCT visitor_hash) FROM page_views WHERE created_at > @cutoff" ;
271+ cmd . Parameters . AddWithValue ( "@cutoff" , cutoff30 ) ;
272+ visitors30d = ( long ) cmd . ExecuteScalar ( ) ! ;
273+ }
274+
275+ var dailyViews = new List < object > ( ) ;
276+ using ( var cmd = conn . CreateCommand ( ) )
277+ {
278+ cmd . CommandText = """
279+ SELECT DATE(created_at) as day, COUNT(*) as views, COUNT(DISTINCT visitor_hash) as visitors
280+ FROM page_views WHERE created_at > @cutoff
281+ GROUP BY DATE(created_at) ORDER BY day
282+ """ ;
283+ cmd . Parameters . AddWithValue ( "@cutoff" , cutoff30 ) ;
284+ using var reader = cmd . ExecuteReader ( ) ;
285+ while ( reader . Read ( ) )
286+ dailyViews . Add ( new { day = reader . GetString ( 0 ) , views = reader . GetInt64 ( 1 ) , visitors = reader . GetInt64 ( 2 ) } ) ;
287+ }
288+
289+ var topReferrers = new List < object > ( ) ;
290+ using ( var cmd = conn . CreateCommand ( ) )
291+ {
292+ cmd . CommandText = """
293+ SELECT referrer, COUNT(*) as count
294+ FROM page_views WHERE created_at > @cutoff AND referrer IS NOT NULL AND referrer != ''
295+ GROUP BY referrer ORDER BY count DESC LIMIT 10
296+ """ ;
297+ cmd . Parameters . AddWithValue ( "@cutoff" , cutoff30 ) ;
298+ using var reader = cmd . ExecuteReader ( ) ;
299+ while ( reader . Read ( ) )
300+ topReferrers . Add ( new { referrer = reader . GetString ( 0 ) , count = reader . GetInt64 ( 1 ) } ) ;
301+ }
302+
303+ return Results . Json ( new
304+ {
305+ sharing = new { total_shared = totalShared , active_plans = activePlans , daily = dailyShares } ,
306+ traffic = new
307+ {
308+ today = new { views = viewsToday , visitors = visitorsToday } ,
309+ last_7d = new { views = views7d , visitors = visitors7d } ,
310+ last_30d = new { views = views30d , visitors = visitors30d } ,
311+ daily = dailyViews ,
312+ top_referrers = topReferrers
313+ }
314+ } ) ;
315+ } ) ;
316+
127317app . MapDelete ( "/api/plans/{id}" , ( string id , HttpContext ctx ) =>
128318{
129319 var token = ctx . Request . Query [ "token" ] . FirstOrDefault ( ) ;
@@ -188,21 +378,33 @@ private void Cleanup()
188378 {
189379 try
190380 {
191- var now = DateTime . UtcNow . ToString ( "o" ) ;
192381 using var conn = new SqliteConnection ( _config . ConnectionString ) ;
193382 conn . Open ( ) ;
194- using var cmd = conn . CreateCommand ( ) ;
195- cmd . CommandText = "DELETE FROM plans WHERE expires_at < @now" ;
196- cmd . Parameters . AddWithValue ( "@now" , now ) ;
197- var deleted = cmd . ExecuteNonQuery ( ) ;
198- if ( deleted > 0 )
383+
384+ // Delete expired plans
385+ var now = DateTime . UtcNow . ToString ( "o" ) ;
386+ using ( var cmd = conn . CreateCommand ( ) )
387+ {
388+ cmd . CommandText = "DELETE FROM plans WHERE expires_at < @now" ;
389+ cmd . Parameters . AddWithValue ( "@now" , now ) ;
390+ var deleted = cmd . ExecuteNonQuery ( ) ;
391+ if ( deleted > 0 )
392+ _logger . LogInformation ( "Cleaned up {Count} expired plans" , deleted ) ;
393+ }
394+
395+ // Delete page views older than 90 days
396+ using ( var cmd = conn . CreateCommand ( ) )
199397 {
200- _logger . LogInformation ( "Cleaned up {Count} expired plans" , deleted ) ;
398+ cmd . CommandText = "DELETE FROM page_views WHERE created_at < @cutoff" ;
399+ cmd . Parameters . AddWithValue ( "@cutoff" , DateTime . UtcNow . AddDays ( - 90 ) . ToString ( "o" ) ) ;
400+ var deleted = cmd . ExecuteNonQuery ( ) ;
401+ if ( deleted > 0 )
402+ _logger . LogInformation ( "Cleaned up {Count} old page views" , deleted ) ;
201403 }
202404 }
203405 catch ( Exception ex )
204406 {
205- _logger . LogError ( ex , "Error during plan cleanup" ) ;
407+ _logger . LogError ( ex , "Error during cleanup" ) ;
206408 }
207409 }
208410}
0 commit comments