168 lines
5.7 KiB
C#
168 lines
5.7 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using TrackQrApi.Data;
|
|
using TrackQrApi.Models;
|
|
|
|
namespace TrackQrApi.Features.Events.Services;
|
|
|
|
public interface IEventTrackingService
|
|
{
|
|
Task TrackClickAsync(Guid workspaceId, Guid shortLinkId, HttpContext context);
|
|
Task TrackScanAsync(Guid workspaceId, Guid shortLinkId, Guid qrCodeId, HttpContext context);
|
|
}
|
|
|
|
public class EventTrackingService(
|
|
IServiceScopeFactory scopeFactory,
|
|
IGeoIpService geoIpService,
|
|
ILogger<EventTrackingService> logger)
|
|
: IEventTrackingService
|
|
{
|
|
// Dedupe window - same visitor clicking same link within this window counts as one
|
|
private static readonly TimeSpan DedupeWindow = TimeSpan.FromMinutes(30);
|
|
|
|
public Task TrackClickAsync(Guid workspaceId, Guid shortLinkId, HttpContext context)
|
|
{
|
|
// Fire and forget - don't block the redirect
|
|
_ = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
await TrackEventInternalAsync(workspaceId, shortLinkId, null, EventType.Click, context);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to track click event for link {ShortLinkId}", shortLinkId);
|
|
}
|
|
});
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task TrackScanAsync(Guid workspaceId, Guid shortLinkId, Guid qrCodeId, HttpContext context)
|
|
{
|
|
// Fire and forget - don't block the redirect
|
|
_ = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
await TrackEventInternalAsync(workspaceId, shortLinkId, qrCodeId, EventType.Scan, context);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to track scan event for QR {QRCodeId}", qrCodeId);
|
|
}
|
|
});
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private async Task TrackEventInternalAsync(
|
|
Guid workspaceId,
|
|
Guid shortLinkId,
|
|
Guid? qrCodeId,
|
|
EventType eventType,
|
|
HttpContext context)
|
|
{
|
|
// Create a new scope for database access (since we're in a background task)
|
|
using var scope = scopeFactory.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
|
|
var ipAddress = GetClientIpAddress(context);
|
|
var userAgent = context.Request.Headers.UserAgent.ToString();
|
|
var referrer = context.Request.Headers.Referer.ToString();
|
|
|
|
var ipHash = HashIpAddress(ipAddress);
|
|
var deviceType = ParseDeviceType(userAgent);
|
|
var countryCode = geoIpService.GetCountryCode(ipAddress);
|
|
var dedupeKey = GenerateDedupeKey(ipHash, shortLinkId, qrCodeId);
|
|
|
|
// Check for duplicate within the dedupe window
|
|
var cutoff = DateTime.UtcNow.Subtract(DedupeWindow);
|
|
var isDuplicate = await db.Events
|
|
.AnyAsync(e => e.DedupeKey == dedupeKey && e.Timestamp > cutoff);
|
|
|
|
if (isDuplicate)
|
|
{
|
|
logger.LogDebug("Skipping duplicate event for link {ShortLinkId}", shortLinkId);
|
|
return;
|
|
}
|
|
|
|
var evt = new Event
|
|
{
|
|
WorkspaceId = workspaceId,
|
|
ShortLinkId = shortLinkId,
|
|
QRCodeId = qrCodeId,
|
|
Type = eventType,
|
|
Timestamp = DateTime.UtcNow,
|
|
IpHash = ipHash,
|
|
UserAgent = TruncateString(userAgent, 512),
|
|
Referrer = TruncateString(referrer, 2048),
|
|
CountryCode = countryCode,
|
|
DeviceType = deviceType,
|
|
DedupeKey = dedupeKey
|
|
};
|
|
|
|
db.Events.Add(evt);
|
|
await db.SaveChangesAsync();
|
|
|
|
logger.LogDebug("Tracked {EventType} event for link {ShortLinkId}", eventType, shortLinkId);
|
|
}
|
|
|
|
private static string GetClientIpAddress(HttpContext context)
|
|
{
|
|
// Check for forwarded headers (when behind a proxy/load balancer)
|
|
var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
|
if (!string.IsNullOrEmpty(forwardedFor))
|
|
// Take the first IP in the chain (client IP)
|
|
return forwardedFor.Split(',')[0].Trim();
|
|
|
|
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
|
}
|
|
|
|
private static string HashIpAddress(string ipAddress)
|
|
{
|
|
// Use SHA256 to hash the IP for privacy
|
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(ipAddress));
|
|
return Convert.ToHexString(bytes)[..16]; // First 16 chars is enough
|
|
}
|
|
|
|
private static string ParseDeviceType(string userAgent)
|
|
{
|
|
if (string.IsNullOrEmpty(userAgent))
|
|
return "Unknown";
|
|
|
|
var ua = userAgent.ToLowerInvariant();
|
|
|
|
// Check for mobile devices
|
|
if (Regex.IsMatch(ua, @"mobile|android|iphone|ipad|ipod|blackberry|windows phone"))
|
|
{
|
|
if (Regex.IsMatch(ua, @"ipad|tablet|android(?!.*mobile)"))
|
|
return "Tablet";
|
|
return "Mobile";
|
|
}
|
|
|
|
// Check for bots/crawlers
|
|
if (Regex.IsMatch(ua, @"bot|crawler|spider|slurp|googlebot|bingbot"))
|
|
return "Bot";
|
|
|
|
return "Desktop";
|
|
}
|
|
|
|
private static string GenerateDedupeKey(string ipHash, Guid shortLinkId, Guid? qrCodeId)
|
|
{
|
|
// Combine IP hash + link ID + optional QR ID
|
|
var combined = $"{ipHash}:{shortLinkId}:{qrCodeId?.ToString() ?? "none"}";
|
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(combined));
|
|
return Convert.ToHexString(bytes)[..32];
|
|
}
|
|
|
|
private static string? TruncateString(string? value, int maxLength)
|
|
{
|
|
if (string.IsNullOrEmpty(value))
|
|
return null;
|
|
|
|
return value.Length <= maxLength ? value : value[..maxLength];
|
|
}
|
|
} |