using System.Net.Http; using Microsoft.AspNetCore.Mvc; using Finch.Mvc; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; namespace Finch.Logging; [ApiController] [Route("/api/hello")] public class AnalyticsController : FinchController { protected IOptionsMonitor Options { get; } protected ILogger Logger { get; } protected IMemoryCache Cache { get; } protected HttpClient Http { get; } public AnalyticsController(IOptionsMonitor options, ILogger logger, IMemoryCache cache, HttpClient http) { Options = options; Logger = logger; Cache = cache; Http = http; // clear script cache when options change options.OnChange(_ => { cache.Remove(ScriptCacheKey); }); } private const string ScriptCacheKey = "umami_script_cache"; [HttpGet("friend.js")] public async Task GetTrackerJsFile() { if (!Options.CurrentValue.ProxyUrl.HasValue()) { Logger.LogWarning("No proxy defined for umami analytics. Cancelling script load"); return NotFound(); } // try to deliver the file from cache if (Cache.TryGetValue(ScriptCacheKey, out string cachedString) && cachedString is not null) { return Content(cachedString, "text/javascript"); } // load and cache the JS file try { string uri = Options.CurrentValue.ProxyUrl + Options.CurrentValue.ProxyScriptEndpoint; string response = await Http.GetStringAsync(uri, HttpContext.RequestAborted); // do not push tracking-id via data-attribute but inject it into the file directly const string replacementText = "(\"website-id\")"; int websiteIdIndex = response.IndexOf(replacementText, StringComparison.Ordinal) - 1; response = response .Remove(websiteIdIndex, replacementText.Length + 1) .Insert(websiteIdIndex, $"\"{Options.CurrentValue.TrackingId}\""); Cache.Set(ScriptCacheKey, response, new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24) }); return Content(response, "text/javascript"); } catch (Exception ex) { Logger.LogError(ex, "Failed to load umami script"); } return NotFound(); } [HttpPost("ping")] public async Task Ping() { if (!Options.CurrentValue.ProxyUrl.HasValue()) { Logger.LogWarning("No proxy defined for umami analytics. Cancelling ping"); return NotFound(); } // create proxy request string uri = Options.CurrentValue.ProxyUrl + Options.CurrentValue.ProxyTrackEndpoint; using HttpRequestMessage proxyRequest = new(HttpMethod.Post, uri); // forward real IP proxyRequest.Headers.TryAddWithoutValidation("X-Forwarded-For", HttpContext.Connection.RemoteIpAddress?.ToString()); proxyRequest.Headers.TryAddWithoutValidation("X-Real-IP", HttpContext.Connection.RemoteIpAddress?.ToString()); // append additional headers from client foreach (KeyValuePair header in Request.Headers) { if (!proxyRequest.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray())) { proxyRequest.Content ??= new StreamContent(Request.Body); proxyRequest.Content.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()); } } proxyRequest.Content ??= new StreamContent(Request.Body); // add requested content-type if (!string.IsNullOrEmpty(Request.ContentType)) { proxyRequest.Content.Headers.TryAddWithoutValidation("Content-Type", Request.ContentType); } // send proxy request to umami try { using HttpResponseMessage proxyResponse = await Http.SendAsync(proxyRequest, HttpCompletionOption.ResponseHeadersRead, HttpContext.RequestAborted); string responseBody = await proxyResponse.Content.ReadAsStringAsync(HttpContext.RequestAborted); return StatusCode((int)proxyResponse.StatusCode, responseBody); } catch (Exception ex) when(ex is not TaskCanceledException and not OperationCanceledException) { Logger.LogError(ex, "Failed to contact umami service"); } return NotFound(); } }