initial implementation of umami tracking
This commit is contained in:
@@ -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<AnalyticsOptions> Options { get; }
|
||||
protected ILogger<AnalyticsController> Logger { get; }
|
||||
protected IMemoryCache Cache { get; }
|
||||
protected HttpClient Http { get; }
|
||||
|
||||
public AnalyticsController(IOptionsMonitor<AnalyticsOptions> options, ILogger<AnalyticsController> 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<IActionResult> 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<IActionResult> 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<string, StringValues> 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<AnalyticsOptions> 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");
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,7 @@ public class FinchMetadataModule : FinchModule
|
||||
public override void ConfigureServices(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddScoped<IMetadataService, MetadataService>();
|
||||
services.AddHttpClient<AnalyticsController>();
|
||||
services.AddOptions<AnalyticsOptions>().Bind(configuration.GetSection("Finch:Analytics"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user