using System; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using API.Constants; using API.Entities.Enums; using API.Entities.Progress; using API.Extensions; using API.Helpers; using API.Services.Reading; using API.Services.Store; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; namespace API.Middleware; /// /// Middleware that extracts client information from the HTTP request and makes it /// available through IClientInfoAccessor for the duration of the request. /// public partial class ClientInfoMiddleware(RequestDelegate next, ILogger logger) { public async Task InvokeAsync(HttpContext context, IUserContext userContext) { var clientInfo = ExtractClientInfo(context, userContext); var clientFingerprint = context.Request.Headers[Headers.ClientDeviceFingerprint].ToString(); ClientInfoAccessor.SetClientInfo(clientInfo); ClientInfoAccessor.SetUiFingerprint(clientFingerprint); await next(context); } private ClientInfoData ExtractClientInfo(HttpContext context, IUserContext userContext) { var userAgent = context.Request.Headers.UserAgent.ToString(); var kavitaClient = context.Request.Headers[Headers.KavitaClient].ToString(); var ipAddress = GetClientIpAddress(context); var authType = userContext.GetAuthenticationType(); var platform = BrowserHelper.DetectPlatform(userAgent); // If custom Kavita header exists, parse it for rich info if (!string.IsNullOrEmpty(kavitaClient)) { var parsed = ParseKavitaClientHeader(kavitaClient, userAgent); parsed.IpAddress = ipAddress; parsed.AuthType = authType; parsed.CapturedAt = DateTime.UtcNow; if (parsed.Platform == ClientDevicePlatform.Unknown) { parsed.Platform = platform; } return parsed; } // Fallback to basic UA parsing var clientType = BrowserHelper.DetermineClientType(userAgent, context.Request.Path.Value); return new ClientInfoData { UserAgent = userAgent, IpAddress = ipAddress, AuthType = authType, ClientType = clientType, Platform = platform, DeviceType = BrowserHelper.CoaxDeviceType(clientType, platform), CapturedAt = DateTime.UtcNow }; } private ClientInfoData ParseKavitaClientHeader(string header, string fallbackUa) { try { // Parse: "web-app/1.2.3 (Chrome/120.0; Windows; Desktop; 1920x1080; landscape)" var match = UserAgentRegex().Match(header); if (match.Success) { // We can ignore if it fails or not as the default will be Unknown, which is fine EnumExtensions.TryParse(match.Groups["platform"].Value, out var clientDevicePlatform); return new ClientInfoData { ClientType = ClientDeviceType.WebApp, AppVersion = match.Groups["appVersion"].Value, Browser = match.Groups["browser"].Value, BrowserVersion = match.Groups["browserVersion"].Value, Platform = clientDevicePlatform, DeviceType = match.Groups["deviceType"].Value, ScreenWidth = int.Parse(match.Groups["screenWidth"].Value), ScreenHeight = int.Parse(match.Groups["screenHeight"].Value), Orientation = match.Groups["orientation"].Success ? match.Groups["orientation"].Value : null, UserAgent = fallbackUa }; } } catch (Exception ex) { logger.LogWarning(ex, "Failed to parse X-Kavita-Client header: {Header}", header.Sanitize()); } // Fallback if parsing fails return new ClientInfoData { UserAgent = fallbackUa, ClientType = ClientDeviceType.WebApp }; } // TODO: Turn this into an extension? private static string GetClientIpAddress(HttpContext context) { // Check for X-Forwarded-For header (proxy/load balancer) var forwardedFor = context.Request.Headers[Headers.ForwardedFor].FirstOrDefault(); if (!string.IsNullOrEmpty(forwardedFor)) { // Take the first IP in the chain return forwardedFor.Split(',')[0].Trim(); } // Check for X-Real-IP header var realIp = context.Request.Headers[Headers.RealIp].FirstOrDefault(); if (!string.IsNullOrEmpty(realIp)) { return realIp; } // Fallback to direct connection IP return context.Connection.RemoteIpAddress?.ToString() ?? string.Empty; } [GeneratedRegex(@"web-app/(?[^\s]+) \((?[^/]+)/(?[^;]+); (?[^;]+); (?[^;]+); (?\d+)x(?\d+)(?:; (?[^\)]+))?\)")] private static partial Regex UserAgentRegex(); }