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)