From 443e384af98078f18acfb090f6dd5fecda6af0aa Mon Sep 17 00:00:00 2001 From: Tobias Klika Date: Tue, 14 Apr 2026 13:57:18 +0200 Subject: [PATCH] initial implementation of umami tracking --- Finch/Metadata/AnalyticsController.cs | 130 ++++++++++++++++++++ Finch/Metadata/AnalyticsOptions.cs | 21 ++++ Finch/Metadata/AnalyticsTrackerTagHelper.cs | 35 ++++++ Finch/Metadata/MetadataModule.cs | 2 + 4 files changed, 188 insertions(+) create mode 100644 Finch/Metadata/AnalyticsController.cs create mode 100644 Finch/Metadata/AnalyticsOptions.cs create mode 100644 Finch/Metadata/AnalyticsTrackerTagHelper.cs diff --git a/Finch/Metadata/AnalyticsController.cs b/Finch/Metadata/AnalyticsController.cs new file mode 100644 index 00000000..419e123c --- /dev/null +++ b/Finch/Metadata/AnalyticsController.cs @@ -0,0 +1,130 @@ +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.Metadata; + +[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) + { + Logger.LogError(ex, "Failed to contact umami service"); + } + + return NotFound(); + } +} \ No newline at end of file diff --git a/Finch/Metadata/AnalyticsOptions.cs b/Finch/Metadata/AnalyticsOptions.cs new file mode 100644 index 00000000..201d4d7b --- /dev/null +++ b/Finch/Metadata/AnalyticsOptions.cs @@ -0,0 +1,21 @@ +namespace Finch.Metadata; + +public class AnalyticsOptions +{ + public bool Enabled { get; set; } = true; + + public string Endpoint { get; set; } = "/api/hello"; + + public string ProxyUrl { get; set; } + + public string ProxyScriptEndpoint { get; set; } = "/hi.js"; + + public string ProxyTrackEndpoint { get; set; } = "/ping"; + + public string TrackingId { get; set; } + + public bool Valid() + { + return Enabled && TrackingId.HasValue() && ProxyUrl.HasValue(); + } +} \ No newline at end of file diff --git a/Finch/Metadata/AnalyticsTrackerTagHelper.cs b/Finch/Metadata/AnalyticsTrackerTagHelper.cs new file mode 100644 index 00000000..f6545b4b --- /dev/null +++ b/Finch/Metadata/AnalyticsTrackerTagHelper.cs @@ -0,0 +1,35 @@ +using Finch.Metadata; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace Finch.TagHelpers; + +[HtmlTargetElement("app-tracker", TagStructure = TagStructure.NormalOrSelfClosing)] +public class AnalyticsTrackerTagHelper(IOptionsMonitor options, IHostEnvironment env) : TagHelper +{ + [HtmlAttributeNotBound] + [ViewContext] + public ViewContext ViewContext { get; set; } = null!; + + private readonly AnalyticsOptions _options = options.CurrentValue; + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + if (!_options.Valid() || !env.IsProduction()) + { + output.SuppressOutput(); + return; + } + + output.TagName = "script"; + output.TagMode = TagMode.StartTagAndEndTag; + output.Attributes.SetAttribute("defer", "true"); + output.Attributes.SetAttribute("src", _options.Endpoint + "/friend.js"); + // the website is injected into the script + //output.Attributes.SetAttribute("data-website-id", _options.TrackingId); + output.Attributes.SetAttribute("data-performance", "true"); + } +} \ No newline at end of file diff --git a/Finch/Metadata/MetadataModule.cs b/Finch/Metadata/MetadataModule.cs index 4d3e7c39..722ba26e 100644 --- a/Finch/Metadata/MetadataModule.cs +++ b/Finch/Metadata/MetadataModule.cs @@ -8,5 +8,7 @@ public class FinchMetadataModule : FinchModule public override void ConfigureServices(IServiceCollection services, IConfiguration configuration) { services.AddScoped(); + services.AddHttpClient(); + services.AddOptions().Bind(configuration.GetSection("Finch:Analytics")); } } \ No newline at end of file