using PrivaPub.Models.Statistics; using System.Text.RegularExpressions; namespace PrivaPub.Infrastructure.Statistics { public static partial class Interactions { public const string Receive = "recv"; public const string In = "in"; public const string Out = "out"; public const string Http = "http"; public const string Preview = "preview"; public const string Crawl = "crawl"; public const string Queued = "queued"; public const string Refused = "refused"; public const string Accepted = "accepted"; public const string Dropped = "dropped"; public const string Rejected = "rejected"; public const string Ok = "ok"; public const string Failed = "failed"; public const string Deferred = "deferred"; public const string Retry = "retry"; public const string Dead = "dead"; public const string Public = "public"; public const string Unlisted = "unlisted"; public const string Private = "private"; public const string None = "none"; public const string Unknown = "-"; public const string Other = "other"; static readonly HashSet Channels = new(StringComparer.Ordinal) { Receive, In, Out, Http, Preview, Crawl }; static readonly HashSet Audiences = new(StringComparer.Ordinal) { Public, Unlisted, Private, None }; static readonly HashSet LocalKinds = new(StringComparer.Ordinal) { "person", "group", "application" }; static readonly HashSet Inboxes = new(StringComparer.Ordinal) { "shared", "personal" }; static readonly int[] LatencyBuckets = { 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000 }; static readonly int[] WaitBuckets = { 1, 10, 60, 600, 3600, 21600, 86400 }; public static InteractionEvent Sanitize(InteractionEvent e) { e.Host = Host(e.Host); e.Channel = Channels.Contains(e.Channel) ? e.Channel : Other; e.Activity = TypeName(e.Activity); e.Object = TypeName(e.Object); e.Purpose = Word(e.Purpose); e.Trigger = Word(e.Trigger); e.Outcome = Word(e.Outcome); e.Reason = Word(e.Reason); e.Audience = e.Audience != default && Audiences.Contains(e.Audience) ? e.Audience : default; e.LocalKind = e.Audience is Public or Unlisted && e.LocalKind != default && LocalKinds.Contains(e.LocalKind) ? e.LocalKind : default; e.Inbox = e.Inbox != default && Inboxes.Contains(e.Inbox) ? e.Inbox : default; e.Signature = e.Signature == default ? default : SignatureName().IsMatch(e.Signature) ? e.Signature : Other; e.Features = e.Features?.Select(Feature).Where(f => f != default).Distinct().Take(32).ToList(); if (e.Features?.Count == 0) e.Features = default; return e; } public static string Host(string host) { host = host?.Trim().TrimEnd('.').ToLowerInvariant(); return string.IsNullOrEmpty(host) || host.Length > 253 || !HostName().IsMatch(host) ? Unknown : host; } public static string HostOf(string uri) => Uri.TryCreate(uri, UriKind.Absolute, out var parsed) ? Host(parsed.Host) : Unknown; public static string Latency(int milliseconds) => Bucket("le", milliseconds, LatencyBuckets, "ms"); public static string Wait(int milliseconds) => Bucket("le", (int)Math.Ceiling(milliseconds / 1000.0), WaitBuckets, "s"); static string Bucket(string prefix, int value, int[] limits, string unit) { foreach (var limit in limits) if (value <= limit) return $"{prefix}{limit}{unit}"; return "inf"; } static string TypeName(string value) => value == default ? default : TypeWord().IsMatch(value) ? value : Other; static string Word(string value) => value == default ? default : KebabWord().IsMatch(value) ? value : Other; static string Feature(string value) => value == default ? default : FeatureName().IsMatch(value) ? value : Other; [GeneratedRegex("^[A-Za-z][A-Za-z0-9_-]{0,31}$")] private static partial Regex TypeWord(); [GeneratedRegex("^[a-z0-9][a-z0-9-]{0,39}$")] private static partial Regex KebabWord(); [GeneratedRegex("^[a-z0-9][a-z0-9:-]{0,39}$")] private static partial Regex SignatureName(); [GeneratedRegex("^[a-z0-9][a-z0-9:/+.-]{0,63}$")] private static partial Regex FeatureName(); [GeneratedRegex("^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$")] private static partial Regex HostName(); } }