From c3dbecca2a10890262b25b5298be02c58ce601a3 Mon Sep 17 00:00:00 2001 From: Kieran Date: Wed, 7 Sep 2022 12:40:52 +0100 Subject: [PATCH] Plausible Analytics --- VoidCat/Model/Extensions.cs | 3 + VoidCat/Model/VoidSettings.cs | 11 +++ VoidCat/Program.cs | 2 + .../Abstractions/IWebAnalyticsCollector.cs | 6 ++ .../Services/Analytics/AnalyticsMiddleware.cs | 32 +++++++++ .../Services/Analytics/AnalyticsStartup.cs | 21 ++++++ .../Services/Analytics/PlausibleAnalytics.cs | 71 +++++++++++++++++++ VoidCat/VoidStartup.cs | 2 + 8 files changed, 148 insertions(+) create mode 100644 VoidCat/Services/Abstractions/IWebAnalyticsCollector.cs create mode 100644 VoidCat/Services/Analytics/AnalyticsMiddleware.cs create mode 100644 VoidCat/Services/Analytics/AnalyticsStartup.cs create mode 100644 VoidCat/Services/Analytics/PlausibleAnalytics.cs diff --git a/VoidCat/Model/Extensions.cs b/VoidCat/Model/Extensions.cs index 6a90f63..bbc3565 100644 --- a/VoidCat/Model/Extensions.cs +++ b/VoidCat/Model/Extensions.cs @@ -236,4 +236,7 @@ public static class Extensions public static bool HasVirusScanner(this VoidSettings settings) => settings.VirusScanner?.ClamAV != default || settings.VirusScanner?.VirusTotal != default; + + public static bool HasPlausible(this VoidSettings settings) + => settings.PlausibleAnalytics?.Endpoint != null; } \ No newline at end of file diff --git a/VoidCat/Model/VoidSettings.cs b/VoidCat/Model/VoidSettings.cs index 2cbbd90..f3cb150 100644 --- a/VoidCat/Model/VoidSettings.cs +++ b/VoidCat/Model/VoidSettings.cs @@ -90,6 +90,11 @@ namespace VoidCat.Model /// Select which store to use for files storage, if not set "local-disk" will be used /// public string DefaultFileStore { get; init; } = "local-disk"; + + /// + /// Plausible Analytics endpoint url + /// + public PlausibleSettings? PlausibleAnalytics { get; init; } } public sealed class TorSettings @@ -158,4 +163,10 @@ namespace VoidCat.Model public Uri? Url { get; init; } public string? EgressQuery { get; init; } } + + public sealed class PlausibleSettings + { + public Uri? Endpoint { get; init; } + public string? Domain { get; init; } + } } \ No newline at end of file diff --git a/VoidCat/Program.cs b/VoidCat/Program.cs index b54dfa5..2c836b2 100644 --- a/VoidCat/Program.cs +++ b/VoidCat/Program.cs @@ -2,6 +2,7 @@ using Newtonsoft.Json; using Prometheus; using VoidCat; using VoidCat.Model; +using VoidCat.Services.Analytics; using VoidCat.Services.Migrations; JsonConvert.DefaultSettings = () => VoidStartup.ConfigJsonSettings(new()); @@ -90,6 +91,7 @@ if (mode.HasFlag(RunModes.Webserver)) app.UseHealthChecks("/healthz"); + app.UseMiddleware(); app.UseEndpoints(ep => { ep.MapControllers(); diff --git a/VoidCat/Services/Abstractions/IWebAnalyticsCollector.cs b/VoidCat/Services/Abstractions/IWebAnalyticsCollector.cs new file mode 100644 index 0000000..5cd368e --- /dev/null +++ b/VoidCat/Services/Abstractions/IWebAnalyticsCollector.cs @@ -0,0 +1,6 @@ +namespace VoidCat.Services.Abstractions; + +public interface IWebAnalyticsCollector +{ + Task TrackPageView(HttpContext context); +} \ No newline at end of file diff --git a/VoidCat/Services/Analytics/AnalyticsMiddleware.cs b/VoidCat/Services/Analytics/AnalyticsMiddleware.cs new file mode 100644 index 0000000..d8a4005 --- /dev/null +++ b/VoidCat/Services/Analytics/AnalyticsMiddleware.cs @@ -0,0 +1,32 @@ +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.Analytics; + +public class AnalyticsMiddleware : IMiddleware +{ + private readonly ILogger _logger; + private readonly IEnumerable _collectors; + + public AnalyticsMiddleware(IEnumerable collectors, ILogger logger) + { + _collectors = collectors; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + foreach (var collector in _collectors) + { + try + { + await collector.TrackPageView(context); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to track page view"); + } + } + + await next(context); + } +} \ No newline at end of file diff --git a/VoidCat/Services/Analytics/AnalyticsStartup.cs b/VoidCat/Services/Analytics/AnalyticsStartup.cs new file mode 100644 index 0000000..f67ee3b --- /dev/null +++ b/VoidCat/Services/Analytics/AnalyticsStartup.cs @@ -0,0 +1,21 @@ +using VoidCat.Model; +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.Analytics; + +public static class AnalyticsStartup +{ + /// + /// Add services needed to collect analytics + /// + /// + /// + public static void AddAnalytics(this IServiceCollection services, VoidSettings settings) + { + services.AddTransient(); + if (settings.HasPlausible()) + { + services.AddTransient(); + } + } +} \ No newline at end of file diff --git a/VoidCat/Services/Analytics/PlausibleAnalytics.cs b/VoidCat/Services/Analytics/PlausibleAnalytics.cs new file mode 100644 index 0000000..02c8f17 --- /dev/null +++ b/VoidCat/Services/Analytics/PlausibleAnalytics.cs @@ -0,0 +1,71 @@ +using System.Text; +using Newtonsoft.Json; +using VoidCat.Model; +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.Analytics; + +public class PlausibleAnalytics : IWebAnalyticsCollector +{ + private readonly HttpClient _client; + private readonly string _domain; + + public PlausibleAnalytics(HttpClient client, VoidSettings settings) + { + _client = client; + _client.BaseAddress = settings.PlausibleAnalytics!.Endpoint!; + _domain = settings.PlausibleAnalytics!.Domain!; + } + + public async Task TrackPageView(HttpContext context) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/api/event"); + request.Headers.Add("user-agent", context.Request.Headers.UserAgent.First()); + request.Headers.Add("x-forwarded-for", + context.Request.Headers.TryGetValue("x-forwarded-for", out var xff) ? xff.First() : null); + + var ub = new UriBuilder("http:", context.Request.Host.Host, context.Request.Host.Port ?? 80, + context.Request.Path) + { + Query = context.Request.QueryString.Value + }; + + var ev = new EventObj(_domain, ub.Uri) + { + Referrer = + context.Request.Headers.Referer.Any() + ? new Uri(context.Request.Headers.Referer.FirstOrDefault()!) + : null + }; + request.Content = new ByteArrayContent(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(ev))); + request.Content.Headers.ContentType = new("application/json"); + + var rsp = await _client.SendAsync(request); + if (!rsp.IsSuccessStatusCode) + { + throw new Exception( + $"Invalid plausible analytics response {rsp.StatusCode} {await rsp.Content.ReadAsStringAsync()}"); + } + } + + internal class EventObj + { + public EventObj(string domain, Uri url) + { + Domain = domain; + Url = url; + } + + [JsonProperty("name")] public string Name { get; init; } = "pageview"; + + [JsonProperty("domain")] public string Domain { get; init; } + + [JsonProperty("url")] public Uri Url { get; init; } + + [JsonProperty("screen_width")] public int? ScreenWidth { get; init; } + + [JsonProperty("referrer")] public Uri? Referrer { get; init; } + + [JsonProperty("props")] public object? Props { get; init; } + } +} \ No newline at end of file diff --git a/VoidCat/VoidStartup.cs b/VoidCat/VoidStartup.cs index e5c80d3..2ff5aec 100644 --- a/VoidCat/VoidStartup.cs +++ b/VoidCat/VoidStartup.cs @@ -12,6 +12,7 @@ using StackExchange.Redis; using VoidCat.Model; using VoidCat.Services; using VoidCat.Services.Abstractions; +using VoidCat.Services.Analytics; using VoidCat.Services.Background; using VoidCat.Services.Captcha; using VoidCat.Services.Files; @@ -150,6 +151,7 @@ public static class VoidStartup }); services.AddTransient(); + services.AddAnalytics(voidSettings); } public static void AddBackgroundServices(this IServiceCollection services, VoidSettings voidSettings)